diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md new file mode 100644 index 00000000..b5a3804f --- /dev/null +++ b/.changeset/calm-apples-inspect.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index ad53816f..83391cea 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -48,6 +48,7 @@ import { maybeNotifyUpdate } from "./lib/update-check.ts"; import { CURRENT_VERSION } from "./lib/version.ts"; import { registerExtras } from "@clerk/cli-extras"; import { + discardCommandTelemetry, finalizeAndSendTelemetry, startCommandTelemetry, telemetryResultForError, @@ -61,6 +62,18 @@ export type Program = Command<[], { inputJson?: string; mode?: string; verbose?: type CommandRegistrant = (program: Program) => void; +/** + * `init --dry-run` promises an invocation-wide read-only boundary. Keep the + * check here, outside the init action, so global hooks cannot send telemetry, + * fetch an update, or persist their caches around an otherwise read-only run. + */ +function isReadOnlyInitDryRun(actionCommand: { + name(): string; + getOptionValue(key: string): unknown; +}): boolean { + return actionCommand.name() === "init" && actionCommand.getOptionValue("dryRun") === true; +} + const registrants: CommandRegistrant[] = [ registerInit, registerAuth, @@ -109,8 +122,15 @@ export function createProgram(): Program { .option("--verbose", "Show detailed output (enables debug messages)") as Program; program.hook("preAction", async (_thisCommand, actionCommand) => { + const readOnlyInitDryRun = isReadOnlyInitDryRun(actionCommand); // First so hook-time failures (e.g. invalid --mode) still produce an event. - startCommandTelemetry(actionCommand); + // A read-only iOS inspection is the exception: its boundary covers global + // command hooks as well as the init action itself. + if (readOnlyInitDryRun) { + discardCommandTelemetry(); + } else { + startCommandTelemetry(actionCommand); + } // Reset log level at the start of each command invocation so a previous // --verbose doesn't leak into subsequent runs. setLogLevel("info"); @@ -125,6 +145,11 @@ export function createProgram(): Program { setMode(opts.mode as Mode); } + // Environment selection only affects remote Clerk operations. Avoid even + // reading or rendering persisted CLI environment state for this local-only + // inspection path. + if (readOnlyInitDryRun) return; + // Initialize the active environment from persisted config const envName = await getEnvironment(); if (envName && isValidEnv(envName)) { @@ -150,6 +175,7 @@ export function createProgram(): Program { // Show update notification after each command, except for commands that // already perform their own version check (doctor, update). program.hook("postAction", async (_thisCommand, actionCommand) => { + if (isReadOnlyInitDryRun(actionCommand)) return; const cmdName = actionCommand.name(); if (cmdName === "doctor" || cmdName === "update") return; await maybeNotifyUpdate(CURRENT_VERSION); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4ccd8c9d..9f520aff 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -27,6 +27,8 @@ const mockPatchInstanceConfig = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockFetchApplication = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); const mockGetApplicationDomainStatus = mock(); @@ -49,6 +51,8 @@ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -228,6 +232,8 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); stubCreateProductionInstance(); mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), @@ -261,6 +267,8 @@ describe("deploy", () => { mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockFetchApplication.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -1253,6 +1261,213 @@ describe("deploy", () => { expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback"); }); + test("skips Apple web credential prompts for an exact native-only production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + + expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + const err = stripAnsi(captured.err); + expect(err).toContain("No deploy actions remain."); + expect(err).toContain("OAuth Apple"); + expect(err).not.toContain("Configure Apple OAuth for production"); + }); + + test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "the production instance does not have an exact iOS Native Application registration for that Bundle ID", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("preserves Ctrl-C while verifying a native-only Apple registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications + .mockRejectedValueOnce(new Error("native status endpoint unavailable")) + .mockRejectedValueOnce(promptExitError()); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toMatchObject({ exitCode: EXIT_CODE.SIGINT }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Paused"); + }); + + test("refuses native-only Apple when production Native API is disabled", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ + object: "native_settings", + api_enabled: false, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Enable Native API at https://dashboard.clerk.com/~/native-applications", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("refuses native-only Apple that is not explicitly authenticatable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + }, + }, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Apple is not explicitly enabled for authentication on the production instance", + ); + + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, @@ -1306,6 +1521,8 @@ describe("deploy", () => { "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", }, }); + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); const p8Input = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Apple Private Key"), )?.[0] as { validate: (value: string) => Promise }; diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 3e691ec1..91af5562 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, @@ -566,6 +570,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(); @@ -600,6 +608,73 @@ async function collectAndSaveOAuthCredentials( return true; } +async function nativeAppleCredentialsAreAlreadyConfigured( + ctx: DeployContext, + descriptor: OAuthProviderDescriptor, + productionInstanceId: string, +): Promise { + if (descriptor.provider !== "apple") return false; + + const productionConfig = await withSpinner( + "Checking production Sign in with Apple configuration...", + async () => fetchInstanceConfig(ctx.appId, productionInstanceId), + ); + const preliminary = inspectNativeAppleConfiguration(productionConfig, descriptor, []); + if (preliminary.status === "authentication-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but Apple is not explicitly enabled for authentication on the production instance. ` + + "Review the Apple connection in the Clerk Dashboard, then rerun `clerk deploy`. No Apple web credentials were requested.", + ); + } + if (preliminary.status !== "registration-missing") { + return false; + } + + let iosApplications: Awaited>; + let nativeSettings: Awaited>; + try { + [iosApplications, nativeSettings] = await withSpinner( + "Checking production Native Application settings...", + async () => + Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]), + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + throw new CliError( + `clerk deploy could not verify the production Native Application registration for ${preliminary.bundleId}. ` + + "No Apple web credentials were requested. Verify the exact Bundle ID at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`.", + ); + } + + const nativeConfiguration = inspectNativeAppleConfiguration( + productionConfig, + descriptor, + iosApplications, + nativeSettings, + ); + if (nativeConfiguration.status === "ready") { + log.success( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}; Apple web credentials are not required`, + ); + return true; + } + + if (nativeConfiguration.status === "native-api-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}, but Native API is disabled on the production instance. ` + + "Enable Native API at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); + } + + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but the production instance does not have an exact iOS Native Application registration for that Bundle ID. ` + + "Register it at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); +} + async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) { await setProfile(ctx.profileKey, { ...ctx.profile, diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index b756b386..bb4c88c3 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; import { buildOAuthProviderDescriptors, + inspectNativeAppleConfiguration, providerFields, providerLabel, type OAuthProviderDescriptor, } from "./providers.ts"; -import type { InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { IOSApplication, InstanceConfigSchema } from "../../lib/plapi.ts"; const oauthSchema = (properties: Record) => ({ type: "object", @@ -27,6 +28,21 @@ const basicOAuthSchema = oauthSchema({ }, }); +const appleOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, +}); + const schemaResponse = (properties: Record): InstanceConfigSchema => ({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://clerk.com/schemas/platform-config/2025-01-01", @@ -43,6 +59,17 @@ function descriptorByProvider( return descriptor; } +function iosApplication(bundleId: string): IOSApplication { + return { + object: "ios_application", + id: `ios_${bundleId}`, + app_id_prefix: "ABCDE12345", + bundle_id: bundleId, + created_at: 1, + updated_at: 1, + }; +} + describe("deploy OAuth provider descriptors", () => { test("builds a descriptor for public providers from schema and shared metadata", () => { const result = buildOAuthProviderDescriptors( @@ -147,22 +174,7 @@ describe("deploy OAuth provider descriptors", () => { test("applies Apple production credential overrides", () => { const result = buildOAuthProviderDescriptors( ["apple"], - schemaResponse({ - connection_oauth_apple: oauthSchema({ - client_id: { type: "string", description: "Apple Services ID" }, - client_secret: { - type: "string", - description: "Apple Private Key", - "x-clerk-sensitive": true, - }, - key_id: { type: "string", description: "Apple Key ID" }, - team_id: { type: "string", description: "Apple Team ID" }, - bundle_id: { - type: "string", - description: "iOS app Bundle ID for native Sign in with Apple", - }, - }), - }), + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), ); const apple = descriptorByProvider(result.supported, "apple"); @@ -190,6 +202,105 @@ describe("deploy OAuth provider descriptors", () => { ]); }); + test("recognizes native-only Apple only for an exact production registration", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const config = { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }, + }; + + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.app")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "ready", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.other")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "registration-missing", bundleId: "com.example.app" }); + }); + + test("requires Native API and authenticatable Apple settings for native readiness", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const iosApplications = [iosApplication("com.example.app")]; + const connection = { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }; + + expect( + inspectNativeAppleConfiguration( + { connection_oauth_apple: connection }, + apple, + iosApplications, + { object: "native_settings", api_enabled: false }, + ), + ).toEqual({ status: "native-api-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + ...connection, + authenticatable: false, + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.app", + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + }); + + test("keeps hosted Apple credentials on the hosted OAuth path", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.app", + client_id: "com.example.web", + }, + }, + apple, + [iosApplication("com.example.app")], + ), + ).toEqual({ status: "hosted-or-unconfigured" }); + }); + test("keeps compatibility prompt labels only for behavioral overrides", () => { expect(providerFields("google").map((field) => field.label)).toEqual([ "Client ID", diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 3a2aabc9..fa01c4e6 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -3,7 +3,12 @@ import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { clerkSubdomains } from "./copy.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; -import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { + ConfigSchemaProperty, + IOSApplication, + InstanceConfigSchema, + NativeSettings, +} from "../../lib/plapi.ts"; const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; @@ -62,6 +67,13 @@ export type OAuthProviderDescriptorResult = { unsupported: string[]; }; +export type NativeAppleConfiguration = + | { status: "not-apple" | "hosted-or-unconfigured" } + | { + status: "ready" | "authentication-disabled" | "registration-missing" | "native-api-disabled"; + bundleId: string; + }; + type ProviderOverride = { credentialLabel?: string; redirectLabel?: string; @@ -212,6 +224,51 @@ export function hasProviderRequiredCredentials( }); } +/** + * Distinguish native-only Apple configuration from hosted Apple OAuth without + * treating an unrelated iOS registration as proof. Native-only production + * setup is ready only when it is authenticatable, its explicit Bundle ID has + * an exact registration, and Native API is enabled on that production instance. + */ +export function inspectNativeAppleConfiguration( + config: Record, + descriptor: OAuthProviderDescriptor, + iosApplications: readonly IOSApplication[], + nativeSettings?: NativeSettings, +): NativeAppleConfiguration { + if (descriptor.provider !== "apple") return { status: "not-apple" }; + + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { status: "hosted-or-unconfigured" }; + } + const providerConfig = value as Record; + if (providerConfig.enabled !== true || hasAppleHostedIdentifier(providerConfig)) { + return { status: "hosted-or-unconfigured" }; + } + + const rawBundleId = providerConfig.bundle_id; + const bundleId = typeof rawBundleId === "string" ? rawBundleId.trim() : ""; + if (!bundleId) return { status: "hosted-or-unconfigured" }; + if (providerConfig.authenticatable !== true) { + return { status: "authentication-disabled", bundleId }; + } + + if (!iosApplications.some((application) => application.bundle_id === bundleId)) { + return { status: "registration-missing", bundleId }; + } + return nativeSettings?.api_enabled === true + ? { status: "ready", bundleId } + : { status: "native-api-disabled", bundleId }; +} + +function hasAppleHostedIdentifier(config: Record): boolean { + return ["client_id", "client_secret", "team_id", "key_id"].some((key) => { + const value = config[key]; + return typeof value === "string" && value.trim().length > 0; + }); +} + function buildOAuthProviderDescriptor( provider: string, schema: InstanceConfigSchema, diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 62435c39..e10d2225 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -4,6 +4,8 @@ import type { LiveDeploySnapshot } from "./status.ts"; const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockGetApplicationDomainStatus = mock(); @@ -12,6 +14,8 @@ const mockTriggerApplicationDomainDNSCheck = mock(); mock.module("../../lib/plapi.ts", () => ({ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -46,14 +50,62 @@ const passthroughHandlers = { work({ update: () => {} }), }; +const appleOAuthSchema = { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, +}; + +function mockActiveProductionEnvironment(): void { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { connection_oauth_apple: appleOAuthSchema }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); +} + beforeEach(() => { mockFetchInstanceConfig.mockResolvedValue({}); mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); }); afterEach(() => { mockFetchApplication.mockReset(); mockListApplicationDomains.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -153,6 +205,221 @@ describe("resolveDeployState", () => { expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); } }); + + test("treats exact native-only Apple production registration as complete", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.pending).toBeUndefined(); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(true); + expect(report.oauth).toMatchObject({ complete: true, configured: ["apple"], pending: [] }); + } + expect(mockListIOSApplications).toHaveBeenCalledWith("app_1", "ins_prod"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_1", "ins_prod"); + }); + + test("reports an actionable incomplete state for a missing exact native Apple registration", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.state).toBe("oauth_pending"); + expect(report.oauth.pending).toEqual(["apple"]); + expect(report.nextAction).toContain("com.example.native"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + expect(report.nextAction).not.toContain( + "OAuth providers are missing production credentials: apple", + ); + } + }); + + test("keeps the preliminary native Apple status when native endpoint reads fail", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + }); + } + }); + + test("keeps hosted Apple completion credential-based without reading iOS registrations", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + client_id: "com.example.web", + client_secret: "REDACTED", + team_id: "TEAM123456", + key_id: "KEY1234567", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); + + test("keeps exact native Apple incomplete when production Native API is disabled", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: false }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "native-api-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("Native API is disabled"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + } + }); + + test("requires Apple to be explicitly authenticatable without reading native endpoints", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "authentication-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("not explicitly enabled for authentication"); + expect(report.nextAction).toContain("no web credentials should be added"); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); }); describe("waitForDeployStatus", () => { diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 86e6d3a2..e90a4587 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -1,12 +1,14 @@ import { resolveProfile } from "../../lib/config.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { errorMessage, PlapiError, UserAbortError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { fetchApplication, fetchInstanceConfig, fetchInstanceConfigSchema, + getNativeSettings, getApplicationDomainStatus, listApplicationDomains, + listIOSApplications, triggerApplicationDomainDNSCheck, type ApplicationDomain, type DomainStatusResponse, @@ -25,6 +27,7 @@ import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, hasProviderRequiredCredentials, + inspectNativeAppleConfiguration, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -72,6 +75,11 @@ export interface DeployStatusReport { nextAction: string; } +type NativeAppleReadinessIssue = { + bundleId: string; + reason: "authentication-disabled" | "registration-missing" | "native-api-disabled"; +}; + export type LiveDeploySnapshot = Omit< DeployOperationState, "pending" | "oauthProviders" | "completedOAuthProviders" @@ -84,6 +92,7 @@ export type LiveDeploySnapshot = Omit< componentStatus: DeployComponentStatus; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -214,8 +223,44 @@ export async function resolveLiveDeploySnapshot( domain.id, options, ); + const nativeAppleDescriptor = oauthProviderDescriptors.find( + (descriptor) => descriptor.provider === "apple", + ); + const preliminaryNativeAppleConfiguration = nativeAppleDescriptor + ? inspectNativeAppleConfiguration(productionConfig, nativeAppleDescriptor, []) + : undefined; + let nativeAppleConfiguration = preliminaryNativeAppleConfiguration; + if ( + nativeAppleDescriptor && + preliminaryNativeAppleConfiguration?.status === "registration-missing" + ) { + try { + nativeAppleConfiguration = await withSpinner( + "Reading production Native Application settings...", + async () => { + const [iosApplications, nativeSettings] = await Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]); + return inspectNativeAppleConfiguration( + productionConfig, + nativeAppleDescriptor, + iosApplications, + nativeSettings, + ); + }, + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + log.debug(`Could not read production Native Application settings: ${errorMessage(error)}`); + } + } const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .filter( + (descriptor) => + hasProviderRequiredCredentials(productionConfig, descriptor) || + (descriptor.provider === "apple" && nativeAppleConfiguration?.status === "ready"), + ) .map((descriptor) => descriptor.provider); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), @@ -235,6 +280,16 @@ export async function resolveLiveDeploySnapshot( componentStatus: deployComponentStatusFromDomainStatus(deployStatus), unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + ...(nativeAppleConfiguration && + "bundleId" in nativeAppleConfiguration && + isNativeAppleReadinessIssue(nativeAppleConfiguration.status) + ? { + nativeAppleReadinessIssue: { + bundleId: nativeAppleConfiguration.bundleId, + reason: nativeAppleConfiguration.status, + }, + } + : {}), }; const domainComplete = deployStatus.status === "complete"; @@ -386,6 +441,7 @@ export function buildDeployStatusReport( snapshot.productionInstanceId ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) : null, + snapshot.nativeAppleReadinessIssue, ), }; } @@ -426,13 +482,29 @@ function deployNextAction( componentStatus: DeployComponentStatus, oauthPending: string[], domainsUrl: string | null, + nativeAppleReadinessIssue?: NativeAppleReadinessIssue, ): string { const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + const nativeAppleAction = nativeAppleReadinessIssue + ? nativeAppleReadinessNextAction(nativeAppleReadinessIssue) + : ""; if (state === "complete") { return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; } if (state === "oauth_pending") { + if (nativeAppleReadinessIssue) { + const hostedPending = oauthPending.filter((provider) => provider !== "apple"); + const hostedAction = + hostedPending.length > 0 + ? ` These OAuth providers are also missing production credentials: ${hostedPending.join(", ")}.` + : ""; + return ( + `Domain verified, but setup is incomplete. ${nativeAppleAction}${hostedAction} ` + + "After resolving those items, run `clerk deploy status` again." + + domainsAction + ); + } return ( `Domain verified, but these OAuth providers are missing production credentials: ` + `${oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + @@ -449,14 +521,45 @@ function deployNextAction( if (pendingComponents.length === 0) { return ( `Production setup for ${domain} is still finalizing on Clerk's side. ` + - `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + + (nativeAppleAction ? ` ${nativeAppleAction}` : "") ); } return ( `${pendingComponents.join(", ")} still provisioning for ${domain}. ` + `Re-run \`clerk deploy status\` in a few minutes, DNS propagation can take time.` + - domainsAction + domainsAction + + (nativeAppleAction ? ` ${nativeAppleAction}` : "") + ); +} + +function isNativeAppleReadinessIssue( + status: string, +): status is NativeAppleReadinessIssue["reason"] { + return ( + status === "authentication-disabled" || + status === "registration-missing" || + status === "native-api-disabled" + ); +} + +function nativeAppleReadinessNextAction(issue: NativeAppleReadinessIssue): string { + if (issue.reason === "authentication-disabled") { + return ( + `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + + "Review the Apple connection in the Clerk Dashboard; no web credentials should be added for a native-only setup." + ); + } + if (issue.reason === "native-api-disabled") { + return ( + `Native API is disabled on the production instance for ${issue.bundleId}. ` + + "Enable it at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." + ); + } + return ( + `Native Sign in with Apple is missing an exact production iOS Native Application registration for ${issue.bundleId}. ` + + "Register that Bundle ID at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." ); } diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index e5aa89f4..383e9e29 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -9,6 +9,7 @@ import { stubFetch, useCaptureLog, } from "../../test/lib/stubs.ts"; +import { resolveFetchedApplicationInstance } from "../../lib/config-instance.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); @@ -29,6 +30,7 @@ mock.module("../../lib/spinner.ts", () => ({ type Profile = { workspaceId: string; appId: string; instances: Record }; const _profiles: Record = {}; +let _resolveAppContextCalls = 0; const INSTANCE_ALIASES: Record = { dev: "development", development: "development", @@ -54,7 +56,9 @@ mock.module("../../lib/config.ts", () => ({ if (!id) throw new Error(`No ${env} instance configured. Run \`clerk link\` to set one up.`); return { id, label: env }; }, + resolveFetchedApplicationInstance, resolveAppContext: async (options: { app?: string; instance?: string; cwd?: string }) => { + _resolveAppContextCalls++; if (options.app) { const app = { application_id: "app_1", @@ -154,6 +158,7 @@ describe("env pull", () => { beforeEach(async () => { Object.keys(_profiles).forEach((k) => delete _profiles[k]); + _resolveAppContextCalls = 0; tempDir = await mkdtemp(join(tmpdir(), "clerk-env-pull-test-")); _setConfigDir(tempDir); process.env.CLERK_PLATFORM_API_KEY = "test_key"; @@ -194,6 +199,160 @@ describe("env pull", () => { return pull(options); } + async function resolveKeys( + options: { + app?: string; + instance?: string; + cwd?: string; + includeSecretKey?: boolean; + } = {}, + ) { + const { resolveEnvironmentKeys } = await import("./pull.ts"); + return resolveEnvironmentKeys(options); + } + + test("resolves the linked development publishable key in memory without requesting secrets", async () => { + await setProfile(tempDir, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev", production: "ins_prod" }, + }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ cwd: tempDir }); + + expect(keys).toEqual({ + appId: "app_1", + instanceId: "ins_dev", + instanceLabel: "development", + publishableKey: "pk_test_abc123", + }); + expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); + expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + expect(captured.out).not.toContain("pk_test_abc123"); + expect(captured.err).not.toContain("pk_test_abc123"); + expect(captured.out).not.toContain("sk_test_xyz789"); + expect(captured.err).not.toContain("sk_test_xyz789"); + }); + + test("returns a secret key only when explicitly requested", async () => { + await setProfile(tempDir, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ cwd: tempDir, includeSecretKey: true }); + + expect(keys.secretKey).toBe("sk_test_xyz789"); + expect(new URL(requestedUrl).searchParams.get("include_secret_keys")).toBe("true"); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + }); + + test("resolves an explicit app's development key with one public-only request and no profile lookup", async () => { + const exactApp = { + application_id: "app_exact", + instances: [mockApplication.instances[1], mockApplication.instances[0]], + }; + const requestedUrls: string[] = []; + stubFetch(async (input) => { + requestedUrls.push(input.toString()); + return new Response(JSON.stringify(exactApp), { status: 200 }); + }); + + const keys = await resolveKeys({ + app: "app_exact", + cwd: join(tempDir, "unlinked"), + includeSecretKey: true, + }); + + expect(keys).toEqual({ + appId: "app_exact", + instanceId: "ins_dev", + instanceLabel: "development", + publishableKey: "pk_test_abc123", + }); + expect(requestedUrls).toHaveLength(1); + const requestedUrl = new URL(requestedUrls[0]!); + expect(requestedUrl.pathname).toEndWith("/v1/platform/applications/app_exact"); + expect(requestedUrl.searchParams.has("include_secret_keys")).toBe(false); + expect(_resolveAppContextCalls).toBe(0); + expect(keys).not.toHaveProperty("secretKey"); + expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + expect(captured.out).not.toContain("pk_test_abc123"); + expect(captured.err).not.toContain("pk_test_abc123"); + expect(captured.out).not.toContain("sk_test_xyz789"); + expect(captured.err).not.toContain("sk_test_xyz789"); + }); + + test("uses canonical instance selection for an explicit app", async () => { + let requestCount = 0; + stubFetch(async () => { + requestCount++; + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ app: "app_1", instance: "prod" }); + + expect(keys).toEqual({ + appId: "app_1", + instanceId: "ins_prod", + instanceLabel: "production", + publishableKey: "pk_live_abc123", + }); + expect(requestCount).toBe(1); + expect(_resolveAppContextCalls).toBe(0); + }); + + test("propagates an inaccessible explicit-app fetch without logging credentials", async () => { + const publishableKey = "pk_test_must_not_be_logged"; + const secretKey = "sk_test_must_not_be_logged"; + let requestCount = 0; + stubFetch(async () => { + requestCount++; + return new Response( + JSON.stringify({ + errors: [ + { + code: "resource_not_found", + message: "Application is inaccessible", + meta: { publishableKey, secretKey }, + }, + ], + }), + { status: 404 }, + ); + }); + + let thrown: unknown; + try { + await resolveKeys({ app: "app_inaccessible" }); + } catch (error) { + thrown = error; + } + + const { PlapiError } = await import("../../lib/errors.ts"); + expect(thrown).toBeInstanceOf(PlapiError); + expect((thrown as { context?: string }).context).toBe("Failed to fetch API keys"); + expect(requestCount).toBe(1); + expect(_resolveAppContextCalls).toBe(0); + expect(captured.out).not.toContain(publishableKey); + expect(captured.err).not.toContain(publishableKey); + expect(captured.out).not.toContain(secretKey); + expect(captured.err).not.toContain(secretKey); + }); + test("errors when no profile is linked", async () => { await expect(runEnvPull()).rejects.toThrow("No Clerk project linked"); }); @@ -670,12 +829,18 @@ describe("env pull", () => { // Replace beforeEach's Express package.json with a native Xcode project marker. await rm(join(tempDir, "package.json"), { force: true }); await mkdir(join(tempDir, "MyApp.xcodeproj"), { recursive: true }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); await runEnvPull(); const content = await Bun.file(join(tempDir, ".env")).text(); expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc123"); expect(content).not.toContain("CLERK_SECRET_KEY"); + expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); }); describe("keyless", () => { diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 50d4aa7d..24da9652 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -1,5 +1,9 @@ import { resolve, join, basename } from "node:path"; -import { resolveAppContext, type AppContextOptions } from "../../lib/config.ts"; +import { + resolveAppContext, + resolveFetchedApplicationInstance, + type AppContextOptions, +} from "../../lib/config.ts"; import { fetchApplication } from "../../lib/plapi.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "../../lib/dotenv.ts"; import { @@ -25,6 +29,27 @@ interface EnvPullOptions extends AppContextOptions { file?: string; } +export interface ResolveEnvironmentKeysOptions { + /** Directory whose linked Clerk profile should be resolved. */ + cwd?: string; + /** Application ID to resolve directly without consulting a linked profile. */ + app?: string; + /** Instance alias or ID. Defaults to the linked development instance. */ + instance?: string; + /** Request the instance secret key as well as its publishable key. */ + includeSecretKey?: boolean; +} + +export interface ResolvedEnvironmentKeys { + appId: string; + instanceId: string; + instanceLabel: string; + publishableKey: string; + secretKey?: string; +} + +type ResolvedAppContext = Awaited>; + /** Check whether a file contains Clerk keys (for backwards compat detection). */ async function hasClerkKeys(path: string): Promise { const file = Bun.file(path); @@ -55,6 +80,72 @@ async function resolveTargetFile( return fallback; } +/** + * Resolve an application's selected instance keys without writing them or + * logging their values. With no explicit instance, linked profiles resolve to + * their development instance. Secret keys are neither requested nor returned + * unless the caller opts in. + * + * An explicit application is always resolved through a public-only request. + * This path never consults the current directory's linked profile and ignores + * `includeSecretKey`, so callers can safely resolve client-side credentials. + * + * `resolvedContext` lets command orchestrators that already resolved the + * instance reuse that result without repeating profile or application lookup. + */ +export async function resolveEnvironmentKeys( + options: ResolveEnvironmentKeysOptions, + resolvedContext?: ResolvedAppContext, +): Promise { + if (options.app) { + const app = await withApiContext( + fetchApplication(options.app, { includeSecretKeys: false }), + "Failed to fetch API keys", + ); + const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + if (!resolved.found) { + throw new CliError( + `Instance ${resolved.instanceId} not found in application ${options.app}.`, + { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }, + ); + } + + return { + appId: options.app, + instanceId: resolved.instanceId, + instanceLabel: resolved.instanceLabel, + publishableKey: resolved.instance.publishable_key, + }; + } + + const cwd = options.cwd ?? process.cwd(); + const ctx = resolvedContext ?? (await resolveAppContext({ instance: options.instance, cwd })); + const app = await withApiContext( + fetchApplication(ctx.appId, { includeSecretKeys: options.includeSecretKey === true }), + "Failed to fetch API keys", + ); + + const matched = app.instances.find((instance) => instance.instance_id === ctx.instanceId); + if (!matched) { + throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + + return { + appId: ctx.appId, + instanceId: matched.instance_id, + instanceLabel: ctx.instanceLabel, + publishableKey: matched.publishable_key, + ...(options.includeSecretKey === true && + matched.secret_key && { secretKey: matched.secret_key }), + }; +} + export async function pull(options: EnvPullOptions): Promise { await withGutter("Pulling environment variables", async () => { const cwd = options.cwd ?? process.cwd(); @@ -68,36 +159,30 @@ export async function pull(options: EnvPullOptions): Promise { return; } - const [ctx, preferredEnvFile] = await Promise.all([ + const [ctx, preferredEnvFile, framework] = await Promise.all([ resolveAppContext({ ...options, cwd }), detectEnvFile(cwd), + detectFramework(cwd), ]); const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); const displayPath = options.file ?? basename(targetFile); + // Native platforms configure Clerk with only the publishable key. Avoid + // requesting a secret key that they cannot use; npm/server projects retain + // the existing key-pair behavior. + const includeSecretKey = isNpmFramework(framework ?? {}); await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { - const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); - - const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } + const keys = await resolveEnvironmentKeys( + { cwd, instance: options.instance, includeSecretKey }, + ctx, + ); const publishableKeyName = await detectPublishableKeyName(cwd); const secretKeyName = await detectSecretKeyName(cwd); - // Native platforms (iOS/Android) configure Clerk with only the publishable - // key in client source; a secret key has no use there and their default - // .gitignore templates don't cover .env, so skip writing it entirely - // rather than leaving a live credential in a tracked file. - const framework = await detectFramework(cwd); - const includeSecretKey = isNpmFramework(framework ?? {}); await mergeKeysIntoEnvFile(targetFile, { - [publishableKeyName]: matched.publishable_key, - ...(matched.secret_key && includeSecretKey && { [secretKeyName]: matched.secret_key }), + [publishableKeyName]: keys.publishableKey, + ...(keys.secretKey && { [secretKeyName]: keys.secretKey }), }); }); diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 5da4c01b..bf2614cb 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -18,6 +18,13 @@ clerk init --keyless --fresh clerk init -y clerk init --yes clerk init --no-skills +clerk init --target MyApp +clerk init --target MyApp --yes +clerk init --target MyApp --prebuilt-auth-ui +clerk init --target MyApp --sign-in-with-apple +clerk init --dry-run +clerk init --dry-run --target MyApp +clerk init --dry-run --target MyApp --json ``` ## Options @@ -33,52 +40,101 @@ clerk init --no-skills | `--login` | Force the authenticated flow: log in (interactively if needed) and link a real application instead of keyless keys. Errors in agent mode when unauthenticated (agents can't run OAuth) | | `--template ` | Pre-configure the keyless application at creation: `b2b-saas`, `b2c-saas`, `native`, `waitlist`. Only applies when the run resolves to keyless — errors otherwise (see [Application templates](#application-templates)); cannot be combined with `--login` | | `--fresh` | Replace an existing unclaimed keyless application with a new one, instead of keeping it (see [Keyless breadcrumb](#keyless-breadcrumb)). Only applies when the run resolves to keyless — errors otherwise; cannot be combined with `--login` | +| `--dry-run` | Inspect an existing native iOS project and print a semantic Clerk setup plan without changing local or remote state | +| `--json` | Emit the `--dry-run` inspection and setup plan as structured JSON. Implied in agent mode; requires `--dry-run` | +| `--target ` | Select an iOS application target by target name or PBX object ID for either inspection or setup | +| `--allow-dirty` | Allow iOS setup to update a planned local file that already has changes. Existing bytes still participate in stale-plan and atomic-write validation | +| `--app-id-prefix ` | Apple App ID Prefix to use if the selected iOS Bundle ID needs a new Clerk registration. Never inferred from `DEVELOPMENT_TEAM`; required in agent mode when local/remote evidence cannot supply it | +| `--sign-in-with-apple` | Opt into native Sign in with Apple for the selected iOS target. Adds the exact Apple entitlement and enables the matching native Clerk connection; never requests hosted/web Apple credentials | +| `--prebuilt-auth-ui` | Opt into ClerkKitUI's prebuilt authentication UI for an untouched, safely inspectable SwiftUI starter. Existing or customized application UI is preserved and returned for review instead of being rewritten | | `-y, --yes` | Skip y/n confirmation prompts only. It neither forces nor bypasses keyless — the strategy is picked by auth state, mode, and flags. It does **not** replace an existing unclaimed keyless app — that still requires `--fresh` | | `--no-skills` | Skip the optional agent skills install prompt at the end of init | +## Read-only iOS inspection + +`clerk init --dry-run` takes a separate, read-only path for existing native iOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, entitlements, and locally configured `CLERK_PUBLISHABLE_KEY` metadata. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. + +Publishable-key discovery is target-aware. The inspector can recognize an inline key passed directly to `Clerk.configure(publishableKey:)`, an enabled Run-scheme environment variable, a target-owned `LocalSecrets.plist`, project `.env` files, and a local Clerk keyless breadcrumb. It distinguishes a key that is available to copy from one that the inspected Swift startup code is known to consume. Output contains only redacted source evidence plus the decoded Frontend API host needed for Associated Domains; it never contains the publishable key itself. + +The command does not authenticate, call Clerk APIs, run Xcode, resolve packages, send command telemetry, check for CLI updates, or write project/global CLI files. Publishable key values are never included in output. Flags that imply project creation or already-known remote application state (`--starter`, `--app`, `--app-id-prefix`, `--keyless`, `--login`, `--template`, and `--fresh`) are rejected before inspection. `--sign-in-with-apple` is allowed because dry-run previews only the local entitlement; it reports the Clerk connection as not inspected until a regular authenticated run. + +When multiple iOS application targets are present, the plan is `blocked` until one is selected with `--target `. A blocked plan still exits successfully because the inspection completed; automation should branch on the JSON `status` field. + +## Native iOS local setup + +For a native iOS project, normal `clerk init` re-runs the semantic inspection, builds the complete local plan, previews it with the publishable key redacted, and asks for consent before authentication or local writes. It reuses an existing verified local or remote clerk-ios package when possible; otherwise it adds the official `https://github.com/clerk/clerk-ios` Swift package. For an untouched Clerk integration, it links both `ClerkKit` and `ClerkKitUI` to the exact selected application target so the optional prebuilt `AuthView` path is available. Existing source-proven custom-flow projects remain `ClerkKit`-only unless their source or Xcode graph already requires `ClerkKitUI`. + +For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. + +Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. A proven LocalSecrets placeholder may still receive the linked development key through its target-owned plist; an existing valid value is verified first. Different valid keys, tracked/shared/malformed plists, custom configuration expressions, generated projects, ambiguous targets or startup structures, unsafe paths, and stale inputs are preserved and require review. + +The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. + +The package graph and direct Swift edits are prepared in memory, staged beside their destination files, committed together after exact app/key resolution, and re-inspected as one rollback-aware local transaction. Re-running an already-complete target is byte-for-byte a no-op. The command does not run Xcode, resolve package versions, build the app, edit `Package.resolved`, change signing, or request a secret key. XcodeGen and Tuist output is not edited; update the generator's source specification instead. + +When every selected-target build configuration already points to a readable, target-exclusive XML entitlements file, `clerk init` can add the exact bare `webcredentials:` value to all of those files. When every configuration is missing entitlements and the selected target has exactly one exclusive filesystem-synchronized source root, it can instead create a minimal `/.entitlements` file and attach it with iPhone-device and iPhone-simulator-qualified build settings. Those qualified settings do not affect macOS, visionOS, or other platforms in a multiplatform target. Existing files preserve unrelated entitlements, comments, newline style, and file modes, and all eligible entitlement changes commit in the same stale-input and rollback-aware transaction as the SDK and direct Swift edits. A `?mode=developer` entry is preserved but does not replace the bare entry. Mixed or conflicting entitlements paths, classic or shared destination ambiguity, generated projects, unresolved build settings, malformed or binary plists, and paths outside the invocation root remain review steps. + +The read-only output also includes a native-readiness section for the Bundle ID, literal App ID Prefix evidence, and local Associated Domains coverage. Because `--dry-run` is strictly local-only, remote Native API and iOS registration state is reported as `not-inspected`. A regular authenticated run audits those resources through the Platform API after the local preview. + +If the linked development instance needs remote changes, `clerk init` prints a second, exact plan and asks separately before making them. Existing registrations are never updated or deleted. When a registration is missing, the CLI uses a consistently proven literal App ID Prefix, an explicit `--app-id-prefix`, or a human-entered value. If every selected-target configuration has the same valid `DEVELOPMENT_TEAM`, human mode offers it as a clearly labeled, unverified suggestion and lets the user enter a different prefix; it is never treated as proven evidence or selected non-interactively. Conflicting local evidence or an existing registration with a different prefix blocks before local files are committed. After consent, the guarded local transaction commits first, remote state is re-read, the exact iOS registration is created, Native API is enabled last, and both resources are verified. Remote retries are additive and idempotent: if a remote step fails after local commit, local changes remain and rerunning safely reconciles the remaining work. + +Native Sign in with Apple is an explicit opt-in, either through the human prompt or `--sign-in-with-apple`; `--yes` alone never enables it. The local transaction adds only `com.apple.developer.applesignin = ["Default"]` to every proven selected-target entitlements route. After the exact iOS registration and Native API are ready, the CLI enables the Apple connection for that exact Bundle ID and verifies the final config. It neither asks for nor changes an Apple Services ID, Team ID, Key ID, or private key. Existing hosted Apple fields are preserved. With ClerkKitUI, `AuthView` displays Apple automatically; a custom flow can call `try await Clerk.shared.auth.signInWithApple()`. + +The prebuilt authentication UI is also an explicit, independent opt-in. `--yes`, agent mode, ClerkKitUI linkage, and `--sign-in-with-apple` never select it by themselves. `--prebuilt-auth-ui` can rewrite only the exact untouched SwiftUI starter screen owned by the selected target; existing navigation, state, custom authentication, partial ClerkKitUI integrations, and established application content are preserved and reported as a review step. The generated screen matches the documented native-components quickstart: a `UserButton` signed-out entry presents `AuthView` in a sheet and prefetches Clerk images. It does not gate or replace established application content. Clerk's native components require iOS 17 and the modern ClerkKit/ClerkKitUI products available in clerk-ios 1.0.0 or newer. Before committing an opted-in UI, the authenticated run also inspects the linked Frontend API environment without printing its publishable key; when Apple is already enabled and authenticatable, the same pre-authorized local transaction verifies or adds the required Apple entitlement without changing the remote Apple strategy. + ## Agent Mode When running in agent mode (`--mode agent` or non-TTY), the command runs the full init flow non-interactively: -- All confirmation prompts are auto-skipped (as if `--yes` was passed) +- Confirmation prompts are generally auto-skipped, but changing a native iOS Xcode project requires an explicit `--yes` +- Native iOS remote mutations also require explicit `--yes`; when no existing registration or complete literal evidence supplies the App ID Prefix, pass `--app-id-prefix` +- Native Sign in with Apple additionally requires `--sign-in-with-apple`; `--yes` grants mutation consent but never opts a project into an authentication strategy +- The prebuilt iOS authentication UI additionally requires `--prebuilt-auth-ui`; `--yes` and agent mode never opt into replacing even an eligible starter screen +- `init --dry-run` automatically emits structured JSON, even when `--json` is omitted - For **existing projects**: framework and package manager are auto-detected, no flags required - For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided - Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided - For keyless-capable frameworks with no `--app` and no linked profile: - When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. - When **unauthenticated**, init uses keyless: the app runs on auto-generated dev keys, and init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically. -- For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app ` or link the project first to pull real keys +- For frameworks that require API keys, agent mode normally requires `--app ` or an existing link. A safely inspectable fresh iOS target is the exception: with valid credentials and explicit `--yes`, init can create and link the development application needed by the approved direct-source plan - `--login` while unauthenticated exits with a usage error (agents can't complete the interactive browser login) -- Agent mode never trusts the mere _presence_ of a stored credential the way human mode does — a stored session that turns out to be expired/broken (e.g. keyring holds a stale OAuth session) is validated before init decides it's "authenticated". A broken credential is treated as unauthenticated, which routes a keyless-capable framework to keyless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login +- Agent mode never trusts the mere _presence_ of a credential before native iOS mutation. A Platform API key is validated with a read-only application-list request, and a stored OAuth session must still resolve to a user. Invalid credentials stop native iOS setup before local apply. Elsewhere, a broken credential is treated as unauthenticated, which routes a keyless-capable framework to keyless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login - Agent mode never mints a fresh keyless application over an existing unclaimed one on re-run — see [Keyless breadcrumb](#keyless-breadcrumb) ## Flow +`--dry-run` first detects an existing native iOS project, performs the read-only inspection described above, prints its setup plan, and returns before authentication, linking, SDK installation, scaffolding, or any other setup work. + +The normal setup flow is: + 1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) -2. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a real `CLERK_PLATFORM_API_KEY`, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: +2. **Native iOS only**: validates iOS-specific flags, resolves the current local Clerk profile, inspects the selected target, and previews the complete redacted SDK plus Swift/runtime configuration plan. It obtains one aggregate consent but writes nothing. Agent credentials may be validated with a read-only API call before this preview so an invalid non-interactive invocation cannot proceed; interactive login, application selection/creation, key fetching, and every local write remain after consent +3. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a Platform API key accepted by a read-only PLAPI request, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: - **`--keyless`**: forces keyless mode, even when logged in. Only valid on a keyless-capable framework, and cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically - **`--login`**: forces the authenticated flow. In agent mode while unauthenticated (or while stored credentials are broken) this exits with a usage error, since agents can't complete the interactive browser login - - **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env` + - **Real app target** (`--app`, linked profile, or an approved fresh iOS direct-source plan): authenticates and links if needed, then configures the native runtime directly or pulls API keys for frameworks that consume an env file - **Agent + non-keyless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` - **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" - **Human mode + bootstrap + keyless-capable framework + not authenticated**: uses keyless mode - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't keyless — see [Application templates](#application-templates) and [Keyless breadcrumb](#keyless-breadcrumb) -3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) -4. Displays detected framework and variant -5. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance -6. Installs the appropriate Clerk SDK (skips if already present) -7. Generates a scaffold plan for the detected framework -8. Warns if the git working tree has uncommitted changes -9. Previews planned file changes and asks for confirmation -10. Writes scaffold files to disk -11. Runs project formatters (Prettier/Biome) on generated files -12. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls -13. Prints a summary of created, modified, and skipped files with recommendations -14. **Authenticated mode**: pulls development instance API keys via `clerk env pull` -15. **Keyless mode** (unauthenticated runs whose resolved strategy in step 2 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead -16. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) +4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links or creates/selects the project application via `clerk link` +5. **Eligible native iOS only**: resolves the newly linked application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. A proven existing LocalSecrets path uses its compatibility transaction. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read +6. Displays detected framework and variant +7. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance +8. Installs the appropriate Clerk SDK (skips if already present) +9. Generates a scaffold plan for the detected framework +10. Warns if the git working tree has uncommitted changes +11. Previews planned file changes and asks for confirmation +12. Writes scaffold files to disk +13. Runs project formatters (Prettier/Biome) on generated files +14. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls +15. Prints a summary of created, modified, and skipped files with recommendations +16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native iOS either completes the proven runtime-key handoff or leaves key storage unchanged +17. **Keyless mode** (unauthenticated runs whose resolved strategy in step 3 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead +18. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) ## Framework Detection @@ -100,12 +156,12 @@ Detects the project's framework from `package.json` dependencies (checked top-to Native mobile platforms may not have a `package.json`, so they are detected from project marker files when no npm framework matches: -| Marker files | Framework | Clerk SDK | Publishable Key Env Var | -| ------------------------------------------------------------------- | ---------------- | ------------------------------------- | ----------------------- | -| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | -| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | +| Marker files | Framework | Clerk SDK | Publishable Key Env Var | +| ------------------------------------------------------------------- | ---------------- | ------------------------------------------------- | ----------------------- | +| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` + `ClerkKitUI` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | +| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | -A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over. +A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For iOS, init can edit the selected target's Swift Package Manager graph directly. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing LocalSecrets and ProcessInfo integrations remain compatibility paths. Android still prints the Gradle installation steps. The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--keyless` forces keyless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it. @@ -113,7 +169,7 @@ Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `ya ## Scaffolding -Scaffolding is supported for every detected framework. iOS and Android write no files (their SDKs are not npm packages and their build files are not safe to modify automatically) — instead they print the exact quickstart steps as post-instructions. +Scaffolding is supported for every detected framework. The dedicated iOS preflight may safely update the selected Xcode target's Swift package graph and authorize an exact runtime-key destination before generic scaffolding; remaining iOS work and all Android native setup are printed as post-instructions. All scaffolding is idempotent — files are skipped if they already contain Clerk setup. @@ -232,7 +288,7 @@ Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./f ### iOS (Swift) / Android (Kotlin) -No files are written. The scaffold plan prints the quickstart steps: SDK install (Swift Package Manager for `ClerkKit`/`ClerkKitUI`, Gradle for `com.clerk:clerk-android-*`), enabling the Native API and registering the app on the Dashboard's Native Applications page, and configuring the publishable key in source (`Clerk.configure(...)` / `Clerk.initialize(...)`) by copying it from the pulled env file. +For iOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; proven LocalSecrets/ProcessInfo projects stay on their existing compatibility path. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. Safe XML entitlements files can receive the exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. The authenticated phase then audits and, with separate consent, additively creates the exact iOS registration and enables Native API for the linked development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. ## Agent skills install diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 2df6f467..4b787850 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -1,10 +1,32 @@ -import { test, expect } from "bun:test"; +import { afterAll, afterEach, test, expect } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ios } from "./ios.ts"; import type { ProjectContext } from "./types.ts"; +import { createIOSFixture } from "../ios/test-helpers.ts"; + +const temporaryRoots: string[] = []; +const emptyRoot = await mkdtemp(join(tmpdir(), "clerk-ios-framework-empty-")); + +afterAll(() => rm(emptyRoot, { recursive: true, force: true })); + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function makeIOSFixture(complete: boolean, clerkSDK = true): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete, clerkSDK }); + return root; +} function makeCtx(): ProjectContext { return { - cwd: "/tmp/ios-app", + cwd: emptyRoot, framework: { dep: "ios", name: "iOS (Swift)", @@ -36,20 +58,150 @@ test("writes no files and prints the quickstart steps", async () => { expect( plan.postInstructions.some((i) => i.includes("ClerkKit") && i.includes("ClerkKitUI")), ).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("prebuilt AuthView path"))).toBe(true); expect( plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), ).toBe(true); expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); - // The official quickstart requires injecting Clerk into the SwiftUI - // environment — views read it back via @Environment(Clerk.self). + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + true, + ); + expect(plan.postInstructions.some((i) => i.includes("--prebuilt-auth-ui"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false); + // With no inspectable target, keep the guidance explicitly conditional. expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true); expect(plan.postInstructions.some((i) => i.includes("docs/ios/getting-started/quickstart"))).toBe( true, ); }); -test("references the project's env file for the publishable key", async () => { +test("uses direct @main configuration as the fresh-project default", async () => { const plan = await ios.scaffold({ ...makeCtx(), envFile: ".env.local" }); - expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("single shipping `@main` App"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("value redacted"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(false); + expect( + plan.postInstructions.some( + (i) => i.includes("Run scheme") && i.includes("manual runtime configuration"), + ), + ).toBe(false); +}); + +test("omits manual Native Applications guidance after authenticated remote verification", async () => { + const plan = await ios.scaffold({ ...makeCtx(), iosNativeRemoteReady: true }); + + expect( + plan.postInstructions.some((instruction) => + instruction.includes("dashboard.clerk.com/~/native-applications"), + ), + ).toBe(false); +}); + +test("explains that the prebuilt AuthView exposes Apple automatically after native setup", async () => { + const root = await makeIOSFixture(true); + const plan = await ios.scaffold({ + ...makeCtx(), + cwd: root, + iosTarget: "MyApp", + iosNativeRemoteReady: true, + iosNativeAppleReady: true, + }); + + expect( + plan.postInstructions.some( + (instruction) => + instruction.includes("Native Sign in with Apple is ready") && + instruction.includes("AuthView displays the Apple button automatically"), + ), + ).toBe(true); +}); + +test("keeps a proven LocalSecrets loader as a compatibility path", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-local-secrets-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist loader"))).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("single shipping `@main` App initializer")), + ).toBe(false); + expect(plan.postInstructions.some((i) => i.includes(".env"))).toBe(false); +}); + +test("includes SwiftUI environment injection for the default prebuilt path", async () => { + const root = await makeIOSFixture(false); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + true, + ); +}); + +test("keeps existing custom-flow installation and environment guidance core-only", async () => { + const root = await makeIOSFixture(false, false); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + const installInstruction = plan.postInstructions.find((instruction) => + instruction.includes("github.com/clerk/clerk-ios"), + ); + + expect(installInstruction).toContain("link ClerkKit for this existing custom-flow path"); + expect(installInstruction).not.toContain("ClerkKitUI"); + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("custom ClerkKit"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("ClerkKitUI's prebuilt AuthView"))).toBe( + false, + ); +}); + +test("omits SwiftUI environment injection when it is already present", async () => { + const root = await makeIOSFixture(true); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); +}); + +test("omits locally satisfied setup instructions for the selected target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-satisfied-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const encodedHost = Buffer.from("clerk.example.test$").toString("base64"); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEYpk_test_${encodedHost}`, + ); + + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("Configure Clerk"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + false, + ); + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false); + expect( + plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), + ).toBe(true); }); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 0562a84e..25fbd653 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -1,14 +1,21 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; +import { planIOSDirectConfig } from "../ios/direct-config.ts"; +import { inspectIOSProject } from "../ios/inspect.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "../ios/plan.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "../ios/products.ts"; +import { planIOSRuntimeKey } from "../ios/runtime-key.ts"; +import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; /** * iOS (Swift) support for `clerk init`. * * The Clerk iOS SDK ships via Swift Package Manager and the publishable key is * configured in Swift source (`Clerk.configure(publishableKey:)`), not an env - * file — and adding an SPM dependency requires editing the Xcode project - * bundle, which is not safe to automate. So instead of writing files, this - * scaffolder prints the exact quickstart steps; `clerk init` still links the - * app and pulls real keys so the user can copy the publishable key. + * file. The dedicated iOS apply phase safely handles the selected target's SPM + * product linkage before this scaffolder runs. For a safely inspectable fresh + * SwiftUI target, init configures the linked development publishable key + * directly in the shipping @main App source. Existing LocalSecrets and + * ProcessInfo integrations remain supported compatibility paths. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -19,14 +26,173 @@ export const ios: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "ios", async scaffold(ctx: ProjectContext): Promise { + const inspection = await inspectIOSProject(ctx.cwd, { target: ctx.iosTarget }); + const selection = inspection.selection; + const target = + selection.state === "selected" + ? inspection.appTargets.find( + (candidate) => + candidate.id === selection.targetId && + candidate.projectPath === selection.projectPath, + ) + : undefined; + const productDecision = target ? clerkKitUIInstallDecision(target) : "prebuilt"; + const includeClerkKitUI = productDecision === "prebuilt"; + const hasLocalSecretsConfigure = target?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ); + const hasProcessInfoConfigure = target?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "process-info-environment", + ); + const shouldPlanDirectConfig = + selection.state === "selected" && + target != null && + shouldPlanIOSDirectConfig(inspection, target, productDecision); + const directConfigPlan = + shouldPlanDirectConfig && selection.state === "selected" + ? await planIOSDirectConfig({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const preliminaryConfigureStep = preliminaryPlan.steps.find( + (step) => step.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + selection.state === "selected" && + target != null && + preliminaryConfigureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, target); + const runtimeKeyPlan = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const associatedDomainPlan = + selection.state === "selected" + ? await planIOSAssociatedDomain({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const setupPlan = buildIOSSetupPlan(inspection, { + runtimeKeyPlan: runtimeKeyPlan && { + status: runtimeKeyPlan.status, + blockers: runtimeKeyPlan.blockers, + }, + directConfigPlan, + associatedDomainPlan, + }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const needsAttention = (id: string) => + setupPlan.steps.find((step) => step.id === id)?.status !== "satisfied"; + const packageIsVerified = + target?.packages.package === "remote" || target?.packages.package === "local"; + const requiredProductsLinked = + target?.packages.clerkKit === "linked" && + (!includeClerkKitUI || target.packages.clerkKitUI === "linked"); + const installInstructions = + productDecision === "unknown" + ? [ + "Swift source membership is incomplete. Confirm whether this target should link ClerkKitUI for prebuilt AuthView or remain ClerkKit-only for a custom flow.", + ] + : packageIsVerified && requiredProductsLinked + ? [] + : packageIsVerified && + target?.packages.clerkKit === "linked" && + includeClerkKitUI && + target.packages.clerkKitUI !== "linked" + ? [ + "Link ClerkKitUI from the existing clerk-ios Swift package for the fastest prebuilt AuthView path", + ] + : includeClerkKitUI + ? [ + "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit and ClerkKitUI for the fastest prebuilt AuthView path)", + ] + : [ + "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit for this existing custom-flow path)", + ]; + const requiresSwiftUIEnvironment = + target != null && (target.swift.environmentConsumers.length > 0 || includeClerkKitUI); + const environmentInstructions = needsAttention("inject-clerk-environment") + ? target?.swift.evidenceComplete === true + ? requiresSwiftUIEnvironment + ? [ + "Inject Clerk into the SwiftUI environment so Clerk-aware views can read it via `@Environment(Clerk.self)`: `ContentView().environment(Clerk.shared)`", + ] + : [] + : [ + "If AuthView or another view reads Clerk via `@Environment(Clerk.self)`, inject it with `ContentView().environment(Clerk.shared)`", + ] + : []; + const registrationInstructions = + !ctx.iosNativeRemoteReady && needsAttention("register-native-application") + ? [ + "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", + ] + : []; + const domainInstructions = needsAttention("add-associated-domain") + ? [ + "In Xcode, add the Associated Domains capability with `webcredentials:`", + ] + : []; + const configureInstructions = needsAttention("configure-publishable-key") + ? selection.state === "selected" && configureStep?.status === "blocked" + ? [configureStep.description] + : hasLocalSecretsConfigure + ? [configureStep?.description ?? "Repair the existing LocalSecrets runtime wiring."] + : hasProcessInfoConfigure + ? [ + "Keep the existing ProcessInfo integration connected to CLERK_PUBLISHABLE_KEY in the enabled Run scheme. This is a compatibility path; clerk init does not write or replace Run-scheme variables.", + ] + : [ + 'Configure Clerk directly in the single shipping `@main` App initializer with the selected application\'s development publishable key: `Clerk.configure(publishableKey: "")`. For a safely inspectable SwiftUI target, `clerk init` applies this with the value redacted from previews and output.', + ] + : []; + const authFlowInstructions = needsAttention("add-authentication-flow") + ? [ + productDecision === "core-only" + ? "Complete the signed-out authentication route with the existing custom ClerkKit sign-in/sign-up flow" + : productDecision === "unknown" + ? "Confirm whether the signed-out route should use ClerkKitUI's AuthView or a custom ClerkKit flow" + : "For a pristine SwiftUI placeholder, rerun `clerk init --prebuilt-auth-ui` to add ClerkKitUI's documented UserButton and AuthView sheet; otherwise add a signed-out authentication route with AuthView or a custom ClerkKit flow without replacing existing application UI", + ] + : []; + const nativeAppleInstructions = ctx.iosNativeAppleReady + ? [ + productDecision === "prebuilt" + ? "Native Sign in with Apple is ready; ClerkKitUI's AuthView displays the Apple button automatically" + : productDecision === "core-only" + ? "Native Sign in with Apple is ready; a custom flow can start it with `try await Clerk.shared.auth.signInWithApple()`" + : "Native Sign in with Apple is ready; AuthView displays Apple automatically, while custom flows can call `try await Clerk.shared.auth.signInWithApple()`", + ] + : []; + const callbackInstructions = + needsAttention("wire-auth-callbacks") && productDecision !== "prebuilt" + ? [ + "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk", + ] + : []; + return { actions: [], postInstructions: [ - "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (add both ClerkKit and ClerkKitUI to your target)", - "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", - "In Xcode, add the Associated Domains capability with `webcredentials:`", - `Configure Clerk in your @main App struct: \`Clerk.configure(publishableKey: "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile} after \`clerk env pull\``, - "Inject Clerk into the SwiftUI environment so views can read it via `@Environment(Clerk.self)`: `ContentView().environment(Clerk.shared)`", + ...installInstructions, + ...registrationInstructions, + ...domainInstructions, + ...configureInstructions, + ...nativeAppleInstructions, + ...authFlowInstructions, + ...environmentInstructions, + ...callbackInstructions, "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart", ], }; diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts index b2c16225..a993d656 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 00000000..6b26e0d3 --- /dev/null +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -0,0 +1,1286 @@ +import { test, expect, describe, spyOn } from "bun:test"; + +// Pure spyOn approach — Bun's mock.module globally replaces modules for the +// entire test run, which pollutes other test files that import the same +// modules. spyOn restores cleanly. Shared setup lives in the harness. +import { + useInitHarness, + FAKE_CTX, + loginMod, + linkMod, + pullMod, + config, + frameworkMod, + context, + scaffoldMod, + heuristics, + skillsMod, + bootstrapMod, + iosApplyMod, + nativeRemoteMod, + nativeAppleMod, + plapiMod, + fapiMod, + FAKE_IOS_NATIVE_READINESS, +} from "../../test/lib/init-harness.ts"; +import { init } from "./index.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import type { IOSLocalSetupResult } from "./ios/apply.ts"; +import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts"; +import type { IOSNativeApplePlan } from "./ios/native-apple.ts"; +import type { IOSNativeRemotePlan } from "./ios/native-remote.ts"; +import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts"; + +const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`; + +function nativeIOSContext() { + return { + ...FAKE_CTX, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; +} + +function iosRemotePlan(overrides: Partial = {}): IOSNativeRemotePlan { + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: "ready", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + appIdPrefix: "LEGACY1234", + nativeApi: "required", + registration: "required", + actions: ["Register the iOS application.", "Enable the Native API."], + blockers: [], + ...overrides, + }; +} + +function iosAppleEntitlementPlan( + overrides: Partial = {}, +): IOSAppleEntitlementPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-sign-in-with-apple-entitlement", + status: "ready", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + targetName: "MyApp", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify", expectedHash: "hash" }], + actions: ["Add the native Sign in with Apple entitlement."], + blockers: [], + ...overrides, + }; +} + +function iosNativeApplePlan(overrides: Partial = {}): IOSNativeApplePlan { + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "ready", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + configVersion: "v1_1234abcd", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: false, authenticatable: false }, + desired: { enabled: true, authenticatable: true }, + actions: ["Enable native Sign in with Apple for com.example.MyApp."], + blockers: [], + ...overrides, + }; +} + +function iosPrebuiltAuthPlan(overrides: Partial = {}): IOSPrebuiltAuthPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status: "ready", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + allowDirty: false, + appSourcePath: "MyApp/MyAppApp.swift", + expectedAppSourceHash: "app-hash", + sourcePath: "MyApp/ContentView.swift", + expectedSourceHash: "content-hash", + actions: [], + blockers: [], + ...overrides, + }; +} + +function iosSetupResult(overrides: Partial = {}): IOSLocalSetupResult { + return { + targetName: "MyApp", + nativeReadiness: FAKE_IOS_NATIVE_READINESS, + prebuiltAuthRequested: false, + prebuiltAuthActive: false, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: + overrides.requiresDevelopmentKey ?? overrides.requiresLinkedApp ?? false, + verifiesExistingKey: false, + ...overrides, + }; +} + +describe("init iOS", () => { + const { setup, track } = useInitHarness(); + test("rejects iOS-only apply flags for a non-iOS project before authentication", async () => { + setup({ email: "test@test.com" }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + + await expect(init({ target: "MyApp" })).rejects.toThrow( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + ); + + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("rejects agent --app before project mutation when authentication is unavailable", async () => { + setup({ isAgent: true, email: null }); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "--app requires authentication", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test.each([ + ["invalid-prefix", "sk_test_sensitive_invalid", 401, "invalid Platform API key"], + ["unauthorized", "ak_test_sensitive_unauthorized", 403, "unauthorized Platform API key"], + ])("rejects an %s before native project mutation", async (_case, key, status, reason) => { + const previous = process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_PLATFORM_API_KEY = key; + try { + const { captured } = setup({ isAgent: true, email: null }); + track( + spyOn(plapiMod, "listApplications").mockRejectedValue( + new PlapiError(status, JSON.stringify({ errors: [{ message: reason }] })), + ), + ); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "--app requires authentication", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + } finally { + if (previous === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("preserves Platform API transport failures before native project mutation", async () => { + const key = "ak_test_sensitive_transport"; + const previous = process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_PLATFORM_API_KEY = key; + try { + const { captured } = setup({ isAgent: true, email: null }); + track( + spyOn(plapiMod, "listApplications").mockRejectedValue(new Error("network unavailable")), + ); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "network unavailable", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + } finally { + if (previous === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("default unauthenticated agent iOS init fails before local apply", async () => { + const previous = process.env.CLERK_PLATFORM_API_KEY; + delete process.env.CLERK_PLATFORM_API_KEY; + try { + setup({ isAgent: true, email: null }); + spyOn(context, "gatherContext").mockResolvedValue(nativeIOSContext()); + + await expect(init({ yes: true })).rejects.toThrow( + "Native iOS setup in agent mode requires valid Clerk authentication", + ); + + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + } finally { + if (previous !== undefined) process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("rejects --allow-dirty with --dry-run before project work", async () => { + setup(); + + await expect(init({ dryRun: true, allowDirty: true })).rejects.toThrow( + "--allow-dirty applies only when clerk init is making local changes", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects an invalid App ID Prefix before project work", async () => { + setup(); + + await expect(init({ appIdPrefix: " " })).rejects.toThrow( + "--app-id-prefix must contain between 1 and 255 characters", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects --app-id-prefix with local-only dry-run", async () => { + setup(); + + await expect(init({ dryRun: true, appIdPrefix: "LEGACY1234" })).rejects.toThrow( + "--app-id-prefix cannot be combined with --dry-run", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects a known non-iOS override before bootstrapping or project work", async () => { + setup(); + spyOn(frameworkMod, "lookupFramework").mockReturnValue(FAKE_CTX.framework); + + await expect(init({ framework: "next", target: "MyApp" })).rejects.toThrow( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("never bootstraps when an iOS existing-project flag is present", async () => { + setup(); + spyOn(context, "gatherContext").mockResolvedValue(null); + + await expect(init({ target: "MyApp" })).rejects.toThrow( + "Could not detect an existing native iOS project", + ); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("rejects --starter with iOS existing-project flags before project work", async () => { + setup(); + + await expect(init({ starter: true, target: "MyApp" })).rejects.toThrow( + "require an existing native iOS project", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test.each([ + [{ keyless: true }, "--keyless is not supported for iOS"], + [{ template: "native" as const }, "--template only applies to keyless applications"], + [{ fresh: true }, "--fresh only applies to keyless applications"], + ])("rejects iOS-incompatible flags before Xcode apply", async (flags, message) => { + setup(); + spyOn(context, "gatherContext").mockResolvedValue({ + ...FAKE_CTX, + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }); + + await expect(init({ yes: true, ...flags })).rejects.toThrow(message); + + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("native iOS skips npm SDK install and does not create an unused env file", async () => { + setup({ email: "test@test.com" }); + + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith({ + root: iosCtx.cwd, + target: undefined, + yes: true, + agent: false, + allowDirty: false, + signInWithApple: undefined, + prebuiltAuthUI: undefined, + }); + expect(heuristics.installSdk).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + }); + + test("forwards only an explicit prebuilt AuthView opt-in to iOS preflight", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ + root: iosCtx.cwd, + yes: true, + prebuiltAuthUI: true, + signInWithApple: undefined, + }), + ); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("normalizes Commander's prebuiltAuthUi option before iOS preflight", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + + await init({ yes: true, prebuiltAuthUi: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ + root: iosCtx.cwd, + yes: true, + prebuiltAuthUI: true, + }), + ); + }); + + test("promotes the pre-authorized Apple entitlement when AuthView exposes Apple", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const conditionalApplePlan = iosAppleEntitlementPlan(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: conditionalApplePlan, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const environment = spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(environment).toHaveBeenCalledWith("example.clerk.accounts.dev", {}); + expect(environment).toHaveBeenCalledTimes(2); + expect(commitLocal).toHaveBeenCalledWith( + expect.objectContaining({ + appleEntitlementPlan: conditionalApplePlan, + prebuiltAuthAppleEntitlementPlan: undefined, + }), + undefined, + ); + expect(setupResult.appleEntitlementPlan).toBeUndefined(); + expect(setupResult.prebuiltAuthAppleEntitlementPlan).toBe(conditionalApplePlan); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("drops the conditional Apple entitlement when AuthView will not expose Apple", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + } as never); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(commitLocal).toHaveBeenCalledWith( + expect.objectContaining({ + prebuiltAuthAppleEntitlementPlan: undefined, + }), + undefined, + ); + expect(commitLocal.mock.calls[0]?.[0].appleEntitlementPlan).toBeUndefined(); + }); + + test("fails closed when AuthView Apple availability changes before local commit", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const environment = spyOn(fapiMod, "fetchUserSettings") + .mockResolvedValueOnce({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + } as never) + .mockResolvedValueOnce({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "AuthView methods changed while the approved iOS setup was being prepared", + ); + + expect(environment).toHaveBeenCalledTimes(2); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("blocks before local or remote mutation when required AuthView Apple capability is unsafe", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan({ + status: "blocked", + files: [], + actions: [], + blockers: [{ code: "unsupported-entitlements", message: "Review the entitlements file." }], + }), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "required selected-target entitlement could not be prepared safely", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("redacts malformed or failed AuthView environment responses", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const secret = "provider-secret-must-not-escape"; + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: "yes", + authenticatable: true, + strategy: "oauth_apple", + client_secret: secret, + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "Apple sign-in settings could not be safely determined", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(secret); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("wires a linked development key directly when iOS preflight proves a runtime sink", async () => { + setup({ email: "test@test.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_test" } } as never); + const setupResult = iosSetupResult({ + runtimeKeyPlan, + requiresLinkedApp: true, + }); + const preflightSpy = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const linkSpy = spyOn(linkMod, "link").mockResolvedValue(undefined); + const resolveKeysSpy = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + const applyPlannedSpy = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const scaffoldSpy = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Finish the remaining iOS setup"], + }); + + await init({ yes: true }); + + expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledWith({ + app: "app_test", + cwd: iosCtx.cwd, + }); + expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith( + setupResult, + "pk_test_redacted", + ); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(preflightSpy.mock.invocationCallOrder[0]).toBeLessThan( + linkSpy.mock.invocationCallOrder[0]!, + ); + expect(linkSpy.mock.invocationCallOrder[0]).toBeLessThan( + resolveKeysSpy.mock.invocationCallOrder[0]!, + ); + expect(applyPlannedSpy.mock.invocationCallOrder[0]).toBeLessThan( + scaffoldSpy.mock.invocationCallOrder[0]!, + ); + }); + + test("audits remote native state before local commit and applies it afterward", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_must_not_be_forwarded", + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const prepareRemote = spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan(), + ); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + const scaffold = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: [], + }); + + await init({ yes: true, appIdPrefix: "LEGACY1234" }); + + expect(prepareRemote).toHaveBeenCalledWith({ + applicationId: "app_test", + instanceId: "ins_test", + target: setupResult.nativeReadiness.target, + appIdPrefix: "LEGACY1234", + unverifiedAppIdPrefixSuggestion: setupResult.unverifiedAppIdPrefixSuggestion, + agent: false, + yes: true, + }); + expect(commitLocal).toHaveBeenCalledWith(setupResult, undefined); + expect(applyRemote).toHaveBeenCalledWith(expect.objectContaining({ status: "ready" })); + expect(resolveKeys.mock.invocationCallOrder[0]).toBeLessThan( + prepareRemote.mock.invocationCallOrder[0]!, + ); + expect(prepareRemote.mock.invocationCallOrder[0]).toBeLessThan( + commitLocal.mock.invocationCallOrder[0]!, + ); + expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( + applyRemote.mock.invocationCallOrder[0]!, + ); + expect(applyRemote.mock.invocationCallOrder[0]).toBeLessThan( + scaffold.mock.invocationCallOrder[0]!, + ); + expect(scaffold).toHaveBeenCalledWith(expect.objectContaining({ iosNativeRemoteReady: true })); + }); + + test("applies an explicitly requested native Apple setup only after local and native readiness", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + appleEntitlementPlan: iosAppleEntitlementPlan(), + nativeAppleRequested: true, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + const preflightLocal = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const prepareNative = spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan(), + ); + const applePlan = iosNativeApplePlan(); + const prepareApple = spyOn(nativeAppleMod, "prepareIOSNativeAppleConnection").mockResolvedValue( + applePlan, + ); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const applyNative = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + const applyApple = spyOn(nativeAppleMod, "applyIOSNativeAppleConnection").mockResolvedValue( + undefined, + ); + const scaffold = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: [], + }); + + await init({ yes: true, signInWithApple: true }); + + expect(preflightLocal).toHaveBeenCalledWith(expect.objectContaining({ signInWithApple: true })); + expect(prepareApple).toHaveBeenCalledWith({ + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + nativeApplicationReady: true, + requested: true, + agent: false, + yes: true, + }); + expect(prepareNative.mock.invocationCallOrder[0]).toBeLessThan( + prepareApple.mock.invocationCallOrder[0]!, + ); + expect(prepareApple.mock.invocationCallOrder[0]).toBeLessThan( + commitLocal.mock.invocationCallOrder[0]!, + ); + expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( + applyNative.mock.invocationCallOrder[0]!, + ); + expect(applyNative.mock.invocationCallOrder[0]).toBeLessThan( + applyApple.mock.invocationCallOrder[0]!, + ); + expect(scaffold).toHaveBeenCalledWith( + expect.objectContaining({ iosNativeRemoteReady: true, iosNativeAppleReady: true }), + ); + expect(`${captured.out}\n${captured.err}`).not.toContain("pk_test_must_not_be_forwarded"); + }); + + test("does not opt into native Apple merely because --yes was supplied", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + appleEntitlementPlan: iosAppleEntitlementPlan({ status: "satisfied", actions: [] }), + nativeAppleRequested: false, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }), + ); + + await init({ yes: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ signInWithApple: undefined }), + ); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("does not commit local iOS files when the remote readiness audit blocks", async () => { + setup({ email: "test@test.com" }); + const 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(); + }); + + test("does not mutate remote state when the approved local transaction fails", async () => { + setup({ email: "test@test.com" }); + 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(); + }); + + test("reports partial remote failure without claiming the local setup was rolled back", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockRejectedValue( + new Error("remote mutation failed"), + ); + + await expect(init({ yes: true })).rejects.toThrow( + "Local changes remain intact; rerun clerk init", + ); + + expect(commitLocal).toHaveBeenCalledTimes(1); + }); + + test("does not write a key when the linked app changes during resolution", async () => { + setup({ email: "test@test.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_linked" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ runtimeKeyPlan, requiresLinkedApp: true }), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_changed", + instanceId: "ins_changed", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + + await expect(init({ yes: true })).rejects.toThrow( + "linked Clerk application changed while its iOS publishable key was being resolved", + ); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + }); + + test("does not apply an approved iOS plan with a production instance key", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const productionKey = `pk_live_${Buffer.from("production.clerk.example$").toString("base64")}`; + const setupResult = iosSetupResult({ requiresLinkedApp: true }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_production" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_production", + instanceId: "ins_production", + instanceLabel: "production", + publishableKey: productionKey, + }); + + await expect(init({ yes: true })).rejects.toThrow("limited to the linked development instance"); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(productionKey); + }); + + test("does not commit when the local app link changes after key resolution", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ requiresLinkedApp: true }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_selected" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_selected" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_changed" } } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_selected", + instanceId: "ins_selected", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + + await expect(init({ yes: true })).rejects.toThrow( + "local Clerk application link changed before the approved iOS setup", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + }); + + test("rejects an explicit same-profile app when its existing iOS runtime key is stale", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("explicit-stale.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_same_profile" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const commit = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( + new Error("The existing iOS runtime publishable key does not match the linked app."), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_same_profile", + instanceId: "ins_same_profile", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await expect(init({ yes: true, app: "app_same_profile" })).rejects.toThrow( + "does not match the linked app", + ); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.not.objectContaining({ expectedPublishableKey: expect.anything() }), + ); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(localApply).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("rejects an implicitly linked profile when its existing iOS runtime key is stale", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("implicit-stale.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_implicitly_linked" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( + new Error("The existing iOS runtime publishable key does not match the linked app."), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_implicitly_linked", + instanceId: "ins_implicitly_linked", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await expect(init({ yes: true })).rejects.toThrow("does not match the linked app"); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("matching an existing iOS runtime key is a read-only authenticated no-op", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("matching.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_matching" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_matching", + instanceId: "ins_matching", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await init({ yes: true }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(resolveKeys).toHaveBeenCalledWith({ app: "app_matching", cwd: iosCtx.cwd }); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("an explicit app with no or a different local profile proceeds when the frozen key matches", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const key = `pk_test_${Buffer.from("explicit-match.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) + .mockResolvedValue({ profile: { appId: "app_requested" } } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_requested", + instanceId: "ins_requested", + instanceLabel: "development", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + + await init({ yes: true, app: "app_requested" }); + + expect(localApply.mock.invocationCallOrder[0]).toBeLessThan( + resolveKeys.mock.invocationCallOrder[0]!, + ); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_requested", + cwd: iosCtx.cwd, + createIfMissing: undefined, + skipAutolink: true, + }); + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("reuses the frozen explicit key after a profile race without cwd-based resolution", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const key = `pk_test_${Buffer.from("frozen.clerk.example$").toString("base64")}`; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ profile: { appId: "app_raced" } } as never) + .mockResolvedValue({ profile: { appId: "app_requested" } } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_requested", + instanceId: "ins_requested", + instanceLabel: "development", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + runtimeKeyPlan, + requiresLinkedApp: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + + await init({ yes: true, app: "app_requested" }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(resolveKeys).toHaveBeenCalledWith({ app: "app_requested", cwd: iosCtx.cwd }); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("native framework skips the agent skills install prompt", async () => { + setup({ email: "test@test.com" }); + + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(skillsMod.installSkills).not.toHaveBeenCalled(); + }); + + test("--framework ios without package.json does not trigger bootstrap", async () => { + setup({ email: "test@test.com" }); + + const iosFramework = { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }; + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: iosFramework, + }; + spyOn(frameworkMod, "lookupFramework").mockReturnValue(iosFramework); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(context, "hasPackageJson").mockResolvedValue(false); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true, framework: "ios" }); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 42b7765d..3437032f 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -19,6 +19,7 @@ import { skillsMod, bootstrapMod, nextStepsMod, + iosApplyMod, } from "../../test/lib/init-harness.ts"; import { init } from "./index.ts"; @@ -46,9 +47,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" }); @@ -63,7 +64,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" }); @@ -75,6 +78,22 @@ describe("init", () => { }); }); + test("does not fetch or write keys when an explicit app relink is declined", async () => { + setup({ email: "test@test.com" }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_existing" }, + } as never); + + await expect(init({ yes: true, app: "app_requested" })).rejects.toMatchObject({ + name: "UserAbortError", + }); + + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSRuntimeKeySetup).not.toHaveBeenCalled(); + }); + test("agent mode runs existing-project flow without prompts", async () => { setup({ isAgent: true }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); @@ -472,95 +491,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 fbe20820..7deca546 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -2,10 +2,16 @@ import { createOption } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; import { login } from "../auth/login.js"; import { link } from "../link/index.js"; -import { pull } from "../env/pull.js"; +import { pull, resolveEnvironmentKeys } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; -import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js"; +import { + throwUserAbort, + throwUsageError, + CliError, + errorMessage, + isAuthError, +} from "../../lib/errors.js"; import { lookupFramework, isNpmFramework, @@ -27,6 +33,8 @@ import { } from "../../lib/keyless.js"; import { readSdkKeylessApp } from "../../lib/keyless-target.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; +import { listApplications } from "../../lib/plapi.ts"; +import { decodePublishableKey, fetchUserSettings } from "../../lib/fapi.ts"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -54,6 +62,33 @@ import { } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; +import { inspectIOSProject } from "./ios/inspect.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./ios/plan.ts"; +import { planIOSDirectConfig } from "./ios/direct-config.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./ios/products.ts"; +import { planIOSRuntimeKey } from "./ios/runtime-key.ts"; +import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; +import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; +import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; +import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; +import { + applyIOSLocalSetup, + applyIOSPlannedLocalSetup, + planIOSPrebuiltAuthSDKCompatibility, + planIOSPrebuiltAuthRuntimeBlockers, + type IOSLocalSetupResult, +} from "./ios/apply.ts"; +import { + applyIOSNativeRemoteSetup, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, +} from "./ios/native-remote.ts"; +import { + applyIOSNativeAppleConnection, + prepareIOSNativeAppleConnection, + type IOSNativeApplePlan, +} from "./ios/native-apple.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "./ios/prebuilt-auth-environment.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ @@ -75,17 +110,59 @@ 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); - 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 = { @@ -94,11 +171,17 @@ export async function init(options: InitOptions = {}) { nameOverride: options.name, }; - intro("Setting up Clerk"); + if (!machineOutput) { + intro(options.dryRun ? "Inspecting Clerk setup" : "Setting up Clerk"); + } - 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; @@ -108,6 +191,199 @@ 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}.`, + ); + } + const inspect = async () => inspectIOSProject(ctx.cwd, { target: options.target }); + const inspection = machineOutput + ? await inspect() + : await withSpinner("Inspecting Xcode project...", inspect); + const dryRunSelection = inspection.selection; + const selectedTarget = + dryRunSelection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === dryRunSelection.targetId && + target.projectPath === dryRunSelection.projectPath, + ) + : undefined; + const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; + const inspectedPrebuiltAuthPlan = + dryRunSelection.state === "selected" + ? await planIOSPrebuiltAuth({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const prebuiltAuthActive = + inspectedPrebuiltAuthPlan != null && + inspectedPrebuiltAuthPlan.status !== "blocked" && + (options.prebuiltAuthUI === true || inspectedPrebuiltAuthPlan.status === "satisfied"); + const directConfigPlan = + dryRunSelection.state === "selected" && + selectedTarget && + productDecision && + shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ) + ? await planIOSDirectConfig({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const configureStep = preliminaryPlan.steps.find( + (step) => step.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + dryRunSelection.state === "selected" && + selectedTarget != null && + configureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); + const runtimeKeyPlan = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const prebuiltRuntimeBlockers = prebuiltAuthActive + ? planIOSPrebuiltAuthRuntimeBlockers( + inspection, + directConfigPlan, + runtimeKeyPlan?.status === "ready" ? runtimeKeyPlan : undefined, + ) + : []; + const prebuiltAuthPlan = + inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 + ? { + ...inspectedPrebuiltAuthPlan, + status: "blocked" as const, + actions: [], + blockers: [ + ...inspectedPrebuiltAuthPlan.blockers, + { + code: "runtime-prerequisites" as const, + message: prebuiltRuntimeBlockers.join(" "), + }, + ], + } + : inspectedPrebuiltAuthPlan; + const associatedDomainPlan = + dryRunSelection.state === "selected" + ? await planIOSAssociatedDomain({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const hasLocalAppleIntent = selectedTarget?.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + const appleEntitlementPlan = + dryRunSelection.state === "selected" && + (options.signInWithApple === true || hasLocalAppleIntent === true) + ? await planIOSAppleEntitlement({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const sdkInstallPlan = + dryRunSelection.state === "selected" && selectedTarget != null && prebuiltAuthActive + ? await planIOSPrebuiltAuthSDKCompatibility({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + runtimeKeyPlan: runtimeKeyPlan && { + status: runtimeKeyPlan.status, + blockers: runtimeKeyPlan.blockers, + }, + directConfigPlan, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthPlan, + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + if (machineOutput) { + log.data( + JSON.stringify(createIOSDryRunOutput(inspection, plan, { associatedDomainPlan }), null, 2), + ); + } else { + log.info(formatIOSSetupPlan(inspection, plan, { associatedDomainPlan })); + await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); + } + return; + } + + let iosLocalSetup: IOSLocalSetupResult | undefined; + let iosProfile: Awaited> | undefined; + let preauthenticatedIOSLabel: string | undefined; + if (ctx.framework.dep === "ios") { + // Resolve the local link before the redacted preview. No application key + // is fetched and no local file is written until the user has authorized + // the complete semantic plan. + iosProfile = await resolveProfile(ctx.cwd); + if (agent && validatedAgentAuthLabel === undefined) { + validatedAgentAuthLabel = await validateAgentAuthentication(); + } + if (agent && validatedAgentAuthLabel === null) { + throwUsageError( + "Native iOS setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", + ); + } + + preauthenticatedIOSLabel = agent ? validatedAgentAuthLabel! : undefined; + + iosLocalSetup = await applyIOSLocalSetup({ + root: ctx.cwd, + target: options.target, + yes: options.yes === true, + agent, + allowDirty: options.allowDirty === true, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + }); + if (agent && iosLocalSetup.verifiesExistingKey && !options.app && !iosProfile) { + throwUsageError( + "This iOS target already contains a publishable key. Agent mode cannot choose its matching Clerk application safely; rerun with --app . No local files were changed.", + ); + } + } + await enrichProjectContext(ctx); const optsKeyless = options.keyless === true; @@ -119,14 +395,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. + if (!optsKeyless && agent && validatedAgentAuthLabel === undefined) { + validatedAgentAuthLabel = await validateAgentAuthentication(); + } const authed = optsKeyless ? false : agent - ? await isAuthenticatedForAgent() + ? validatedAgentAuthLabel !== null : await isAuthenticated(); const linkedProfile = - !optsKeyless && agent && !options.app ? await resolveProfile(ctx.cwd) : undefined; - const hasRealAppTarget = Boolean(options.app || linkedProfile); + ctx.framework.dep === "ios" + ? iosProfile + : !optsKeyless && agent && authed && !options.app + ? await resolveProfile(ctx.cwd) + : undefined; + const hasRealAppTarget = Boolean( + options.app || linkedProfile || iosLocalSetup?.requiresLinkedApp, + ); const strategy = pickStrategy({ optsKeyless, @@ -140,12 +425,209 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); + let authenticatedAppId: string | undefined; if (strategy === "authenticate") { bar(); const createIfMissing = agent ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) : undefined; - await authenticateAndLink(ctx.cwd, options.app, createIfMissing); + authenticatedAppId = await authenticateAndLink( + ctx.cwd, + options.app, + createIfMissing, + iosLocalSetup?.requiresLinkedApp === true, + preauthenticatedIOSLabel, + ); + } + + let authenticatedKeysHandled = false; + if (iosLocalSetup?.requiresLinkedApp) { + if (strategy !== "authenticate") { + throw new CliError( + "The approved iOS configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", + ); + } + if (!authenticatedAppId) { + throw new CliError( + "The Clerk application link could not be verified. No local setup changes were written.", + ); + } + const keys = await withSpinner("Fetching the development publishable key...", async () => + resolveEnvironmentKeys({ app: authenticatedAppId, cwd: ctx.cwd }), + ); + if (keys.instanceLabel !== "development") { + throw new CliError( + "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", + ); + } + if (keys.appId !== authenticatedAppId) { + throw new CliError( + "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", + ); + } + let iosSetupForCommit = iosLocalSetup; + let inspectedAuthViewAppleRequirement: "required" | "not-required" | undefined; + if (iosLocalSetup.prebuiltAuthActive) { + const authEnvironment = await withSpinner( + "Inspecting AuthView authentication methods...", + async () => { + try { + const { fapiHost } = decodePublishableKey(keys.publishableKey); + const settings = await fetchUserSettings(fapiHost, {}); + return auditIOSPrebuiltAuthEnvironment(settings); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + log.debug(`Could not inspect AuthView authentication methods: ${errorMessage(error)}`); + throw new CliError( + "The linked Clerk application's AuthView methods could not be inspected safely. No local setup changes were written; rerun clerk init.", + ); + } + }, + ); + if (authEnvironment.apple === "blocked") { + throw new CliError(`${authEnvironment.message} No local setup changes were written.`); + } + inspectedAuthViewAppleRequirement = authEnvironment.apple; + if (authEnvironment.apple === "required") { + const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + const reasons = conditionalPlan?.blockers + .map((blocker) => ` • ${blocker.message}`) + .join("\n"); + throw new CliError( + `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${reasons ? `:\n${reasons}` : "."}`, + ); + } + } + } + const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ + applicationId: keys.appId, + instanceId: keys.instanceId, + target: iosLocalSetup.nativeReadiness.target, + appIdPrefix: options.appIdPrefix, + ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion + ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } + : {}), + agent, + yes: options.yes === true, + }); + let nativeApplePlan: IOSNativeApplePlan | undefined; + if (iosLocalSetup.nativeAppleRequested) { + if (!iosLocalSetup.appleEntitlementPlan) { + throw new CliError( + "Native Sign in with Apple was requested without a validated local entitlement plan. No local or Apple connection changes were written; rerun clerk init.", + ); + } + const target = iosLocalSetup.nativeReadiness.target; + if (target.status !== "selected" || target.bundleIdentifier.status !== "resolved") { + throw new CliError( + "The selected iOS Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", + ); + } + const preparedApple = await prepareIOSNativeAppleConnection({ + applicationId: keys.appId, + instanceId: keys.instanceId, + bundleIdentifier: target.bundleIdentifier.value, + nativeApplicationReady: + nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", + requested: true, + agent, + yes: options.yes === true, + }); + if (preparedApple.status === "skipped") { + throw new CliError( + "Native Sign in with Apple was selected locally but its Clerk connection plan was skipped. No local or Apple connection changes were written; rerun clerk init.", + ); + } + nativeApplePlan = preparedApple; + } + const commitProfile = await resolveProfile(ctx.cwd); + if (commitProfile?.profile.appId !== authenticatedAppId) { + throw new CliError( + "The local Clerk application link changed before the approved iOS setup could be committed. No local or remote setup changes were written; rerun clerk init.", + ); + } + + if (iosLocalSetup.prebuiltAuthActive) { + const authEnvironment = await withSpinner( + "Revalidating AuthView authentication methods...", + async () => { + try { + const { fapiHost } = decodePublishableKey(keys.publishableKey); + const settings = await fetchUserSettings(fapiHost, {}); + return auditIOSPrebuiltAuthEnvironment(settings); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + log.debug( + `Could not revalidate AuthView authentication methods: ${errorMessage(error)}`, + ); + throw new CliError( + "The linked Clerk application's AuthView methods could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", + ); + } + }, + ); + if (authEnvironment.apple === "blocked") { + throw new CliError( + `${authEnvironment.message} No local or remote setup changes were written.`, + ); + } + 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.", + ); + } + + if (authEnvironment.apple === "required") { + const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + throw new CliError( + "AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", + ); + } + iosSetupForCommit = { + ...iosLocalSetup, + appleEntitlementPlan: iosLocalSetup.appleEntitlementPlan ?? conditionalPlan, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } else { + iosSetupForCommit = { + ...iosLocalSetup, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } + } + + await applyIOSPlannedLocalSetup( + iosSetupForCommit, + iosSetupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, + ); + try { + await applyIOSNativeRemoteSetup(nativeRemotePlan); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + log.debug(`Could not reconcile Clerk Native Application settings: ${errorMessage(error)}`); + throw new CliError( + "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", + ); + } + log.success("Clerk Native API and iOS application registration verified"); + ctx.iosNativeRemoteReady = true; + if (nativeApplePlan) { + try { + await applyIOSNativeAppleConnection(nativeApplePlan); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + log.debug(`Could not reconcile the native Apple connection: ${errorMessage(error)}`); + throw new CliError( + "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", + ); + } + ctx.iosNativeAppleReady = true; + } + authenticatedKeysHandled = true; + } else if (iosLocalSetup) { + await applyIOSPlannedLocalSetup(iosLocalSetup); } // Short-circuit on a fully-clean re-run so env pull / skills prompt don't @@ -169,6 +651,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. @@ -192,7 +675,46 @@ export async function init(options: InitOptions = {}) { * application the CLI creates; `--login` and `--app` describe one that * already exists. */ -async function assertUsableFlags(options: InitOptions, agent: boolean): Promise { +function assertUsableFlags(options: InitOptions): void { + if (options.json && !options.dryRun) { + throwUsageError("--json currently requires --dry-run."); + } + if (options.dryRun && options.allowDirty) { + throwUsageError("--allow-dirty applies only when clerk init is making local changes."); + } + if (options.dryRun && options.appIdPrefix != null) { + throwUsageError( + "--app-id-prefix cannot be combined with --dry-run because dry-run never reads or changes remote application state.", + ); + } + if (options.appIdPrefix != null && !validateAppIdPrefix(options.appIdPrefix)) { + throwUsageError("--app-id-prefix must contain between 1 and 255 characters after trimming."); + } + if (options.dryRun && options.starter) { + throwUsageError( + "--dry-run cannot be combined with --starter because dry-run never creates files.", + ); + } + if ( + options.starter && + (options.target || + options.allowDirty || + options.appIdPrefix || + options.signInWithApple || + options.prebuiltAuthUI) + ) { + throwUsageError( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui require an existing native iOS project and cannot be combined with --starter.", + ); + } + if ( + options.dryRun && + (options.app || options.keyless || options.login || options.template || options.fresh) + ) { + throwUsageError( + "--dry-run cannot be combined with --app, --keyless, --login, --template, or --fresh because it never reads or changes remote application state.", + ); + } if (options.keyless && options.login) { throwUsageError("--keyless and --login cannot be combined."); } @@ -209,12 +731,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( - "--login requires an interactive terminal to complete the browser login. Ask the user to run `clerk auth login`, then re-run `clerk init`.", + "--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( + "--fresh only applies to keyless applications, but iOS (Swift) does not support keyless mode. Drop --fresh.", ); } } @@ -225,13 +762,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; } /** @@ -329,6 +876,38 @@ 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.", + ); + } + 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.", + ); + } + return { ctx, bootstrap: null }; +} + // --- Next steps --- function devCommand(pm: string): string { @@ -347,6 +926,17 @@ function printBootstrapNextSteps( } function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { + if (framework.dep === "ios") { + const lines = [ + `\n Set up Clerk for ${framework.name}:`, + " Run `clerk init --app ` to link the project and configure a safely inspectable fresh SwiftUI target automatically.", + ' Manual source setup uses `Clerk.configure(publishableKey: "")` in the shipping @main App initializer and `.environment(Clerk.shared)` on the WindowGroup root.', + " Existing ProcessInfo/Run-scheme and LocalSecrets loaders remain supported compatibility paths; clerk init does not replace a custom runtime source.", + ]; + log.info(lines.map(dim).join("\n")); + return; + } + // Only reachable for non-keyless frameworks: keyless-capable ones resolve to // the "keyless" or "authenticate" strategy in agent mode instead. const lines = [ @@ -408,6 +998,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( @@ -420,6 +1012,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": @@ -445,22 +1043,42 @@ async function authenticateAndLink( cwd: string, app: string | undefined, createIfMissing: string | undefined, -): Promise { - const label = await resolveAuthLabel(); + requireLinkedAppId: boolean, + preauthenticatedLabel?: string, +): Promise { + const label = preauthenticatedLabel ?? (await resolveAuthLabel()); const profile = await resolveProfile(cwd); const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app); if (label && alreadyOnRequestedApp) { log.info(dim(`${label} · Linked to ${profile.profile.appId}`)); - return; + return profile.profile.appId; } if (label) { log.info(dim(label)); } - await link({ skipIfLinked: true, app, cwd, createIfMissing }); + await link({ + skipIfLinked: true, + app, + cwd, + createIfMissing, + ...(requireLinkedAppId && { skipAutolink: true }), + }); + + const linked = app || requireLinkedAppId ? await resolveProfile(cwd) : undefined; + if (app && linked?.profile.appId !== app) { + if (profile) throwUserAbort(); + throw new CliError( + `The project was not linked to the requested Clerk application ${app}. No keys were written.`, + ); + } + if (requireLinkedAppId && !linked) { + throw new CliError("The Clerk application link could not be verified. No keys were written."); + } + return linked?.profile.appId; } // --- Keyless app setup --- @@ -561,8 +1179,9 @@ async function detectAndInstall( } else if (isNpmFramework(ctx.framework)) { 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. return scaffoldAndWrite(cwd, ctx, skipConfirm); } @@ -651,6 +1270,22 @@ export function registerInit(program: Program): void { "--fresh", "Replace an existing unclaimed keyless application with a new one, instead of keeping it. Only applies when the strategy resolves to keyless — errors otherwise", ) + .option( + "--dry-run", + "Inspect an existing iOS project and print a setup plan without changing local or remote state", + ) + .option("--json", "Output the read-only iOS inspection and setup plan as JSON") + .option("--target ", "Select an iOS application target by name or PBX object ID") + .option("--allow-dirty", "Allow an iOS project file with existing local changes to be updated") + .option( + "--app-id-prefix ", + "Apple App ID Prefix to use when Clerk needs to register the selected iOS Bundle ID", + ) + .option("--sign-in-with-apple", "Enable native Sign in with Apple for the selected iOS target") + .option( + "--prebuilt-auth-ui", + "Add ClerkKitUI's prebuilt AuthView flow to a proven pristine SwiftUI target", + ) .option("-y, --yes", "Skip confirmation prompts") .option("--no-skills", "Skip the optional agent skills install prompt") .setExamples([ @@ -684,6 +1319,14 @@ export function registerInit(program: Program): void { command: "clerk init --keyless --fresh", description: "Replace an existing unclaimed keyless app with a new one", }, + { + command: "clerk init --dry-run", + description: "Inspect an iOS project and print its setup plan without changes", + }, + { + command: "clerk init --dry-run --target MyApp --json", + description: "Inspect one iOS app target and emit a machine-readable plan", + }, { command: "clerk init -y", description: "Skip all confirmation prompts" }, { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, ]) diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts new file mode 100644 index 00000000..7f9746bf --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -0,0 +1,360 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, +} from "./associated-domain.ts"; +import { + applyIOSAppleEntitlement, + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, +} from "./apple-entitlement.ts"; +import { applyIOSFileTransaction } from "./file-transaction.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; +const APPLE_KEY = "com.apple.developer.applesignin"; +const HOST = "apple-native.clerk.example"; +const KEY = `pk_test_${Buffer.from(`${HOST}$`).toString("base64")}`; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-apple-entitlement-")); + temporaryDirectories.push(root); + return root; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, options); + return root; +} + +function planOptions(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }; +} + +function appleBlock(value = "Default", newline = "\n"): string { + return [ + `\t${APPLE_KEY}`, + "\t", + `\t\t${value}`, + "\t", + ].join(newline); +} + +async function replaceEntitlements(root: string, body: string, newline = "\n"): Promise { + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = [ + '', + '', + '', + "", + "\t", + body, + "", + "", + "", + ].join(newline); + await writeFile(path, source); + return source; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS Sign in with Apple entitlement setup", () => { + test("adds exactly Default while preserving comments, CRLF newlines, mode, and idempotence", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const original = await replaceEntitlements( + root, + [ + "\tapplication-identifier", + "\tLEGACY1234.com.example.MyApp", + ].join("\r\n"), + "\r\n", + ); + await chmod(path, 0o640); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], + }); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify({ plan, prepared })).not.toContain("candidateBytes"); + expect(JSON.stringify({ plan, prepared })).not.toContain(""); + expect(source).toContain("\r\n"); + expect(source.replace(appleBlock("Default", "\r\n"), "")).toContain(original.split("\r\n")[5]!); + expect((await lstat(path)).mode & 0o7777).toBe(0o640); + + const digest = await treeDigest(root); + const rerun = await planIOSAppleEntitlement(planOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSAppleEntitlement(rerun)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("preserves the closing dict indentation without inserting a whitespace-only line", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = ( + await replaceEntitlements(root, "\texisting\n\tvalue") + ).replace("\n", "\n "); + await writeFile(path, source); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + const updated = await readFile(path, "utf8"); + + expect(result.status).toBe("applied"); + expect(updated).toContain(`${appleBlock()}\n `); + expect(updated).not.toContain("\n \n"); + }); + + test("treats only the exact one-element Default array as satisfied", async () => { + const root = await fixture(); + await replaceEntitlements(root, appleBlock()); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("satisfied"); + expect(plan.actions).toEqual([]); + expect((await prepareIOSAppleEntitlementMutation(plan)).status).toBe("satisfied"); + }); + + test("blocks conflicting, malformed, duplicated, and encoded Apple entitlement values", async () => { + const cases = [ + `${APPLE_KEY}PrimaryApp`, + `${APPLE_KEY}DefaultPrimaryApp`, + `${APPLE_KEY}Default`, + `${APPLE_KEY}Default${APPLE_KEY}Default`, + `com.apple.developer.applesigninDefault`, + ]; + for (const body of cases) { + const root = await fixture(); + await replaceEntitlements(root, body); + const before = await treeDigest(root); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toMatch( + /conflicting-apple-entitlement|unsupported-entitlements/, + ); + expect((await applyIOSAppleEntitlement(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("updates every distinct entitlements variant selected by target configurations", async () => { + const root = await fixture(); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const debugPath = join(root, "MyApp", "MyApp.entitlements"); + const releasePath = join(root, "MyApp", "MyApp-Release.entitlements"); + await writeFile(releasePath, await readFile(debugPath)); + const project = await readFile(projectPath, "utf8"); + const marker = `${IOS_FIXTURE_IDS.targetRelease} = { isa = XCBuildConfiguration;`; + const start = project.indexOf(marker); + const end = project.indexOf("\n ", start + marker.length); + await writeFile( + projectPath, + `${project.slice(0, start)}${project + .slice(start, end) + .replace( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp-Release.entitlements;", + )}${project.slice(end)}`, + ); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.files.map((file) => file.path)).toEqual([ + "MyApp/MyApp-Release.entitlements", + "MyApp/MyApp.entitlements", + ]); + expect(result.status).toBe("applied"); + for (const file of plan.files) { + expect(await readFile(join(root, file.path), "utf8")).toContain(APPLE_KEY); + } + }); + + test("inherits exact-target, generated-project, mixed-path, and shared-file safety blockers", async () => { + const invalid = await fixture(); + expect( + (await planIOSAppleEntitlement({ ...planOptions(invalid), targetId: "missing" })).blockers[0] + ?.code, + ).toBe("invalid-selection"); + + const generated = await fixture({ generated: "tuist" }); + expect((await planIOSAppleEntitlement(planOptions(generated))).blockers[0]?.code).toBe( + "generated-project", + ); + + const mixed = await fixture({ releaseEntitlements: false }); + expect((await planIOSAppleEntitlement(planOptions(mixed))).blockers[0]?.code).toBe( + "mixed-entitlements", + ); + + const shared = await fixture({ secondTarget: true }); + const projectPath = join(shared, "MyApp.xcodeproj", "project.pbxproj"); + let project = await readFile(projectPath, "utf8"); + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + const marker = `${id} = { isa = XCBuildConfiguration; buildSettings = { `; + project = project.replace( + marker, + `${marker}CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; `, + ); + } + await writeFile(projectPath, project); + expect((await planIOSAppleEntitlement(planOptions(shared))).blockers[0]?.code).toBe( + "shared-entitlements", + ); + + const outside = await fixture(); + const unsafe = await fixture(); + const unsafePath = join(unsafe, "MyApp", "MyApp.entitlements"); + await rm(unsafePath); + await symlink(join(outside, "MyApp", "MyApp.entitlements"), unsafePath); + expect((await planIOSAppleEntitlement(planOptions(unsafe))).blockers[0]?.code).toBe( + "unsafe-entitlements", + ); + }); + + test("returns stale without touching a post-preview user edit", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const plan = await planIOSAppleEntitlement(planOptions(root)); + await writeFile(path, "newer user bytes\n"); + + const result = await applyIOSAppleEntitlement(plan); + + expect(result.status).toBe("stale"); + expect(await readFile(path, "utf8")).toBe("newer user bytes\n"); + }); + + test("creates and attaches a missing synchronized-root entitlements file", async () => { + const root = await fixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const path = join(root, "MyApp", "MyApp.entitlements"); + + const plan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], + missingEntitlementsSettings: { status: "ready" }, + }); + expect(result.status).toBe("applied"); + expect(await readFile(path, "utf8")).toContain(appleBlock()); + expect((await lstat(path)).mode & 0o7777).toBe(0o644); + expect((await planIOSAppleEntitlement(planOptions(root))).status).toBe("satisfied"); + }); + + test("composes with the Associated Domains create and PBX candidates", async () => { + const root = await fixture({ includeKey: false }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${KEY}") } + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + allowMissingEntitlementsCreation: true, + }); + const applePlan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + expect(prepared.consumedBaseMutationPaths).toEqual( + associated.mutations.map((mutation) => mutation.path).sort(), + ); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + + const result = await applyIOSFileTransaction(prepared.mutations, [ + () => validatePreparedIOSAppleEntitlement(prepared), + () => validatePreparedIOSAssociatedDomain(associated), + ]); + const source = await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toContain(APPLE_KEY); + expect(source).toContain(`webcredentials:${HOST}`); + }); + + test("composes with an existing entitlements candidate and rolls the aggregate write back", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", "applinks:keep.test"), + ); + const before = await readFile(path); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + const applePlan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + + const result = await applyIOSFileTransaction(prepared.mutations, [() => false]); + + expect(result.status).toBe("rolled-back"); + expect(await readFile(path)).toEqual(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts new file mode 100644 index 00000000..17b6ef3e --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -0,0 +1,816 @@ +import { lstat, readFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import plist from "@expo/plist"; +import { + planIOSAssociatedDomain, + type IOSAssociatedDomainBlockerCode, +} from "./associated-domain.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; +import { isRecord } from "./pbx.ts"; + +const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; +const APPLE_SIGN_IN_VALUE = "Default"; +const MAX_ENTITLEMENTS_BYTES = 1_000_000; + +export type IOSAppleEntitlementBlockerCode = + | IOSAssociatedDomainBlockerCode + | "conflicting-apple-entitlement" + | "invalid-plan"; + +export interface IOSAppleEntitlementBlocker { + code: IOSAppleEntitlementBlockerCode; + message: string; +} + +export interface IOSAppleEntitlementPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface IOSAppleEntitlementPlan { + schemaVersion: 1; + kind: "clerk-ios-sign-in-with-apple-entitlement"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + targetName?: string; + files: IOSAppleEntitlementPlanFile[]; + /** PBX settings needed only when the target has no entitlements file yet. */ + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + actions: string[]; + blockers: IOSAppleEntitlementBlocker[]; +} + +export interface IOSAppleEntitlementPlanOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; + /** Allows the strict synchronized-root planner to create and attach a new file. */ + allowMissingEntitlementsCreation?: boolean; +} + +export interface IOSAppleEntitlementPrepareOptions { + /** + * Previously prepared file candidates to compose with. Candidate bytes remain + * private and must never be serialized into output or telemetry. + */ + baseMutations?: readonly IOSFileMutation[]; +} + +export type PreparedIOSAppleEntitlementMutation = + | { status: "satisfied"; plan: IOSAppleEntitlementPlan } + | { status: "blocked"; plan: IOSAppleEntitlementPlan } + | { status: "stale"; plan: IOSAppleEntitlementPlan } + | { + status: "ready"; + plan: IOSAppleEntitlementPlan; + /** @internal Candidate bytes must never be serialized into output or telemetry. */ + mutations: IOSFileMutation[]; + /** Absolute paths whose caller-supplied candidates were semantically composed. */ + consumedBaseMutationPaths: string[]; + }; + +export interface IOSAppleEntitlementApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSAppleEntitlementPlan; +} + +interface EntitlementsDocument { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + appleState: "absent" | "exact"; +} + +type EntitlementsInspection = + | { status: "safe"; document: EntitlementsDocument } + | { status: "blocked"; blocker: IOSAppleEntitlementBlocker }; + +function blocker( + code: IOSAppleEntitlementBlockerCode, + message: string, +): IOSAppleEntitlementBlocker { + return { code, message }; +} + +function planBase(options: IOSAppleEntitlementPlanOptions) { + return { + schemaVersion: 1 as const, + kind: "clerk-ios-sign-in-with-apple-entitlement" as const, + root: resolve(options.root), + projectPath: options.projectPath.replaceAll("\\", "/"), + targetId: options.targetId, + }; +} + +function blockedPlan( + options: IOSAppleEntitlementPlanOptions, + blockers: IOSAppleEntitlementBlocker[], + targetName?: string, +): IOSAppleEntitlementPlan { + return { + ...planBase(options), + status: "blocked", + ...(targetName ? { targetName } : {}), + files: [], + actions: [], + blockers, + }; +} + +function blockPrepared( + plan: IOSAppleEntitlementPlan, + code: IOSAppleEntitlementBlockerCode, + message: string, +): Extract { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [blocker(code, message)], + }, + }; +} + +function stripXMLCommentsPreservingOffsets(source: string): string { + return source.replace(//g, (comment) => " ".repeat(comment.length)); +} + +function decodeXMLText(value: string): string | undefined { + if (/[<>]/.test(value)) return undefined; + let unsupported = false; + const decoded = value.replace( + /&(?:#x([0-9a-f]+)|#([0-9]+)|(amp|lt|gt|quot|apos));/gi, + (_entity, hex: string | undefined, decimal: string | undefined, named: string | undefined) => { + if (hex) return String.fromCodePoint(Number.parseInt(hex, 16)); + if (decimal) return String.fromCodePoint(Number.parseInt(decimal, 10)); + if (named === "amp") return "&"; + if (named === "lt") return "<"; + if (named === "gt") return ">"; + if (named === "quot") return '"'; + if (named === "apos") return "'"; + unsupported = true; + return ""; + }, + ); + if (unsupported || /&[^;\s]*;/.test(decoded)) return undefined; + return decoded; +} + +function appleKeyStructure(source: string): { + literalCount: number; + semanticCount: number; + safelyDecoded: boolean; +} { + const structural = stripXMLCommentsPreservingOffsets(source); + const literalCount = [ + ...structural.matchAll(/]*>\s*com\.apple\.developer\.applesignin\s*<\/key>/g), + ].length; + let semanticCount = 0; + let safelyDecoded = true; + for (const match of structural.matchAll(/]*>([\s\S]*?)<\/key>/g)) { + const decoded = decodeXMLText(match[1] ?? ""); + if (decoded == null) { + safelyDecoded = false; + continue; + } + if (decoded.trim() === APPLE_SIGN_IN_KEY) semanticCount += 1; + } + return { literalCount, semanticCount, safelyDecoded }; +} + +function inspectEntitlementsBytes( + root: string, + absolutePath: string, + bytes: Uint8Array, + mode: number, +): EntitlementsInspection { + const relativePath = relativeIOSPath(root, absolutePath); + try { + if (bytes.byteLength > MAX_ENTITLEMENTS_BYTES) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} must be an XML plist no larger than 1 MB.`, + ), + }; + } + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} is a binary plist. Save it as XML before automatic setup.`, + ), + }; + } + const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + const source = new TextDecoder("utf-8", { fatal: true }).decode(bom ? bytes.slice(3) : bytes); + const parsed: unknown = plist.parse(source); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + const rawValue = parsed[APPLE_SIGN_IN_KEY]; + const structure = appleKeyStructure(source); + if ( + !structure.safelyDecoded || + structure.literalCount > 1 || + structure.semanticCount > 1 || + (rawValue !== undefined && (structure.literalCount !== 1 || structure.semanticCount !== 1)) || + (rawValue === undefined && (structure.literalCount !== 0 || structure.semanticCount !== 0)) + ) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} does not contain one safely editable literal Sign in with Apple key.`, + ), + }; + } + if (rawValue !== undefined) { + if ( + !Array.isArray(rawValue) || + rawValue.length !== 1 || + rawValue[0] !== APPLE_SIGN_IN_VALUE + ) { + return { + status: "blocked", + blocker: blocker( + "conflicting-apple-entitlement", + `${relativePath} has a conflicting Sign in with Apple entitlement; expected exactly ["Default"].`, + ), + }; + } + } + return { + status: "safe", + document: { + absolutePath, + relativePath, + bytes, + hash: hashIOSFileBytes(bytes), + mode, + source, + bom, + appleState: rawValue === undefined ? "absent" : "exact", + }, + }; + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativePath} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +async function inspectEntitlementsFile( + root: string, + absolutePath: string, +): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + status: "blocked", + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink()) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} must be a regular, non-symlink XML plist.`, + ), + }; + } + return inspectEntitlementsBytes( + root, + absolutePath, + new Uint8Array(await readFile(absolutePath)), + info.mode & 0o7777, + ); + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +function lineIndentAt(source: string, index: number): string { + const start = source.lastIndexOf("\n", index - 1) + 1; + return /^[\t ]*/.exec(source.slice(start, index))?.[0] ?? ""; +} + +function addAppleEntitlementToXML(source: string): string | undefined { + const structural = stripXMLCommentsPreservingOffsets(source); + if (appleKeyStructure(source).semanticCount !== 0) return undefined; + const dictClose = structural.lastIndexOf(""); + if (dictClose < 0) return undefined; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const closingIndent = lineIndentAt(source, dictClose); + const insertionPoint = dictClose - closingIndent.length; + const firstKey = /${APPLE_SIGN_IN_KEY}`, + `${childIndent}`, + `${childIndent}\t${APPLE_SIGN_IN_VALUE}`, + `${childIndent}`, + "", + ].join(newline); + return `${source.slice(0, insertionPoint)}${insertion}${closingIndent}${source.slice(dictClose)}`; +} + +function bytesWithOptionalBOM(source: string, bom: boolean): Uint8Array { + const encoded = new TextEncoder().encode(source); + if (!bom) return encoded; + const bytes = new Uint8Array(encoded.length + 3); + bytes.set([0xef, 0xbb, 0xbf]); + bytes.set(encoded, 3); + return bytes; +} + +function newEntitlementsBytes(): Uint8Array { + return new TextEncoder().encode( + [ + '', + '', + '', + "", + `\t${APPLE_SIGN_IN_KEY}`, + "\t", + `\t\t${APPLE_SIGN_IN_VALUE}`, + "\t", + "", + "", + "", + ].join("\n"), + ); +} + +function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { + return "kind" in mutation && mutation.kind === "create"; +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +function validBaseMutation(mutation: IOSFileMutation): boolean { + return ( + Number.isInteger(mutation.mode) && + mutation.mode >= 0 && + mutation.mode <= 0o7777 && + hashIOSFileBytes(mutation.candidateBytes) === mutation.candidateHash && + (isCreateMutation(mutation) || + hashIOSFileBytes(mutation.originalBytes) === mutation.originalHash) + ); +} + +function preparedWithHiddenMutations( + plan: IOSAppleEntitlementPlan, + mutations: IOSFileMutation[], + consumedBaseMutationPaths: string[], +): Extract { + const result = { + status: "ready" as const, + plan, + consumedBaseMutationPaths: [...consumedBaseMutationPaths].sort(), + } as Extract; + Object.defineProperty(result, "mutations", { + value: mutations, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +function samePlanFiles( + left: readonly IOSAppleEntitlementPlanFile[], + right: readonly IOSAppleEntitlementPlanFile[], +): boolean { + return ( + left.length === right.length && + left.every( + (file, index) => + file.path === right[index]?.path && + file.operation === right[index]?.operation && + file.expectedHash === right[index]?.expectedHash, + ) + ); +} + +function candidateWithApple(root: string, document: EntitlementsDocument): Uint8Array | undefined { + if (document.appleState === "exact") return document.bytes; + const source = addAppleEntitlementToXML(document.source); + if (!source) return undefined; + const bytes = bytesWithOptionalBOM(source, document.bom); + const inspected = inspectEntitlementsBytes(root, document.absolutePath, bytes, document.mode); + return inspected.status === "safe" && inspected.document.appleState === "exact" + ? bytes + : undefined; +} + +/** + * Plans the exact native Sign in with Apple entitlement across every selected + * target entitlements variant. No Apple or Clerk credentials are retained. + */ +export async function planIOSAppleEntitlement( + options: IOSAppleEntitlementPlanOptions, +): Promise { + const normalized = { ...options, root: resolve(options.root) }; + const entitlementProbe = await planIOSAssociatedDomain({ + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + deferToPublishableKey: true, + allowMissingEntitlementsCreation: normalized.allowMissingEntitlementsCreation, + }); + if (entitlementProbe.status === "blocked") { + return blockedPlan( + normalized, + entitlementProbe.blockers.map((item) => blocker(item.code, item.message)), + entitlementProbe.targetName, + ); + } + + const files: IOSAppleEntitlementPlanFile[] = entitlementProbe.files.map((file) => ({ + path: file.path, + operation: file.operation, + ...(file.expectedHash ? { expectedHash: file.expectedHash } : {}), + })); + let allExact = files.length > 0 && files.every((file) => file.operation === "modify"); + for (const file of files) { + if (file.operation === "create") { + allExact = false; + continue; + } + const inspected = await inspectEntitlementsFile( + normalized.root, + resolve(normalized.root, file.path), + ); + if (inspected.status === "blocked") { + return blockedPlan(normalized, [inspected.blocker], entitlementProbe.targetName); + } + if (inspected.document.hash !== file.expectedHash) { + return blockedPlan( + normalized, + [blocker("stale-entitlements", `${file.path} changed while setup was inspected.`)], + entitlementProbe.targetName, + ); + } + if (inspected.document.appleState !== "exact") allExact = false; + } + + return { + ...planBase(normalized), + status: allExact ? "satisfied" : "ready", + ...(entitlementProbe.targetName ? { targetName: entitlementProbe.targetName } : {}), + files, + ...(entitlementProbe.missingEntitlementsSettings + ? { missingEntitlementsSettings: entitlementProbe.missingEntitlementsSettings } + : {}), + actions: allExact + ? [] + : [ + files.some((file) => file.operation === "create") + ? "Create and attach an iOS entitlements file with the Sign in with Apple entitlement set to Default." + : "Set the Sign in with Apple entitlement to Default in every selected-target iOS entitlements configuration.", + ], + blockers: [], + }; +} + +export async function prepareIOSAppleEntitlementMutation( + plan: IOSAppleEntitlementPlan, + options: IOSAppleEntitlementPrepareOptions = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-sign-in-with-apple-entitlement" || + resolve(plan.root) !== plan.root || + !plan.projectPath || + !plan.targetId || + plan.files.length === 0 + ) { + return blockPrepared( + plan, + "invalid-plan", + "The serialized Apple entitlement plan is incomplete.", + ); + } + + const baseByPath = new Map(); + for (const mutation of options.baseMutations ?? []) { + const path = resolve(mutation.path); + if ( + !isAbsolute(mutation.path) || + path !== mutation.path || + baseByPath.has(path) || + !(await pathIsSafelyWithinIOSRoot(plan.root, path)) || + !validBaseMutation(mutation) + ) { + return blockPrepared( + plan, + "invalid-plan", + "A caller-supplied base mutation is invalid, duplicated, or outside the invocation root.", + ); + } + baseByPath.set(path, mutation); + } + + // Compare the exact authorized bytes before reparsing. A concurrent edit + // that also makes the plist malformed is stale input, not a new structural + // blocker, and its newer bytes must remain untouched. + for (const file of plan.files) { + const absolutePath = resolve(plan.root, file.path); + if (!(await pathIsSafelyWithinIOSRoot(plan.root, absolutePath))) { + return blockPrepared( + plan, + "invalid-plan", + "A planned entitlements path no longer resolves safely inside the invocation root.", + ); + } + if (file.operation === "create") { + try { + await lstat(absolutePath); + return { status: "stale", plan }; + } catch (error) { + if (!isMissingFileError(error)) return { status: "stale", plan }; + } + continue; + } + try { + if (!file.expectedHash) + return blockPrepared(plan, "invalid-plan", "A planned file hash is missing."); + const info = await lstat(absolutePath); + if ( + !info.isFile() || + info.isSymbolicLink() || + hashIOSFileBytes(await readFile(absolutePath)) !== file.expectedHash + ) { + return { status: "stale", plan }; + } + } catch { + return { status: "stale", plan }; + } + } + + const replanned = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if (!samePlanFiles(plan.files, replanned.files)) return { status: "stale", plan }; + if (plan.status === "satisfied") { + return replanned.status === "satisfied" + ? { status: "satisfied", plan: replanned } + : { status: "stale", plan }; + } + if (replanned.status !== "ready") return { status: "stale", plan }; + + const createFile = plan.files.find((file) => file.operation === "create"); + if (createFile) { + if ( + plan.files.length !== 1 || + !plan.missingEntitlementsSettings || + createFile.path !== plan.missingEntitlementsSettings.entitlementsPath + ) { + return blockPrepared( + plan, + "invalid-plan", + "The missing-entitlements Apple plan is internally inconsistent.", + ); + } + const entitlementsPath = resolve(plan.root, createFile.path); + const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const baseEntitlements = baseByPath.get(entitlementsPath); + const basePbx = baseByPath.get(pbxprojPath); + if (baseEntitlements && !isCreateMutation(baseEntitlements)) { + return { status: "stale", plan }; + } + if (basePbx && isCreateMutation(basePbx)) { + return blockPrepared( + plan, + "invalid-plan", + "The base Xcode mutation must replace an existing file.", + ); + } + const settings = await prepareIOSMissingEntitlementsSettingsMutation( + plan.missingEntitlementsSettings, + basePbx as IOSExistingFileMutation | undefined, + ); + if (settings.status === "stale") return { status: "stale", plan }; + if (settings.status !== "ready") { + return blockPrepared( + plan, + "invalid-plan", + "The iOS entitlements build settings could not be prepared safely.", + ); + } + + let createMutation: IOSCreateFileMutation; + if (baseEntitlements) { + const expectedIdentity = plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + if ( + !expectedIdentity || + baseEntitlements.expectedParentIdentity.device !== expectedIdentity.device || + baseEntitlements.expectedParentIdentity.inode !== expectedIdentity.inode + ) { + return { status: "stale", plan }; + } + const inspected = inspectEntitlementsBytes( + plan.root, + entitlementsPath, + baseEntitlements.candidateBytes, + baseEntitlements.mode, + ); + if (inspected.status === "blocked") { + return blockPrepared(plan, inspected.blocker.code, inspected.blocker.message); + } + const candidateBytes = candidateWithApple(plan.root, inspected.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + "The composed entitlements candidate could not be updated safely.", + ); + } + createMutation = { + ...baseEntitlements, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + }; + } else { + const expectedParentIdentity = + plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + if ( + !expectedParentIdentity || + dirname(entitlementsPath) !== + resolve(plan.root, plan.missingEntitlementsSettings.synchronizedRootPath ?? "") + ) { + return blockPrepared( + plan, + "invalid-plan", + "The entitlements destination no longer matches its synchronized target root.", + ); + } + const candidateBytes = newEntitlementsBytes(); + createMutation = { + kind: "create", + path: entitlementsPath, + expectedParentIdentity: { ...expectedParentIdentity }, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: 0o644, + }; + } + return preparedWithHiddenMutations( + plan, + [createMutation, settings.mutation], + [...(baseEntitlements ? [entitlementsPath] : []), ...(basePbx ? [pbxprojPath] : [])], + ); + } + + const mutations: IOSExistingFileMutation[] = []; + const consumed: string[] = []; + for (const file of plan.files) { + if (file.operation !== "modify" || !file.expectedHash) { + return blockPrepared( + plan, + "invalid-plan", + "The Apple entitlement plan has an invalid file entry.", + ); + } + const absolutePath = resolve(plan.root, file.path); + const current = await inspectEntitlementsFile(plan.root, absolutePath); + if (current.status === "blocked" || current.document.hash !== file.expectedHash) { + return { status: "stale", plan }; + } + const base = baseByPath.get(absolutePath); + if (base && isCreateMutation(base)) return { status: "stale", plan }; + if ( + base && + (base.originalHash !== file.expectedHash || + base.mode !== current.document.mode || + hashIOSFileBytes(base.originalBytes) !== current.document.hash) + ) { + return { status: "stale", plan }; + } + const source = base + ? inspectEntitlementsBytes(plan.root, absolutePath, base.candidateBytes, base.mode) + : current; + if (source.status === "blocked") { + return blockPrepared(plan, source.blocker.code, source.blocker.message); + } + if (source.document.appleState === "exact") { + if (base && current.document.appleState !== "exact") { + mutations.push(base); + consumed.push(absolutePath); + } + continue; + } + const candidateBytes = candidateWithApple(plan.root, source.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + `${file.path} could not be updated without rewriting unrelated plist content.`, + ); + } + mutations.push({ + path: absolutePath, + originalBytes: base?.originalBytes ?? current.document.bytes, + originalHash: base?.originalHash ?? current.document.hash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: base?.mode ?? current.document.mode, + }); + if (base) consumed.push(absolutePath); + } + if (mutations.length === 0) return { status: "satisfied", plan }; + return preparedWithHiddenMutations(plan, mutations, consumed); +} + +export async function validatePreparedIOSAppleEntitlement( + prepared: Extract, +): Promise { + if ( + prepared.plan.missingEntitlementsSettings && + !(await validateIOSMissingEntitlementsSettingsPostcondition( + prepared.plan.missingEntitlementsSettings, + )) + ) { + return false; + } + const current = await planIOSAppleEntitlement({ + root: prepared.plan.root, + projectPath: prepared.plan.projectPath, + targetId: prepared.plan.targetId, + }); + const expectedPaths = prepared.plan.files.map((file) => file.path).sort(); + return ( + current.status === "satisfied" && + current.files + .map((file) => file.path) + .sort() + .every((path, index) => path === expectedPaths[index]) && + current.files.length === expectedPaths.length + ); +} + +export async function applyIOSAppleEntitlement( + plan: IOSAppleEntitlementPlan, +): Promise { + const prepared = await prepareIOSAppleEntitlementMutation(plan); + if (prepared.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if (prepared.status === "stale") return { status: "stale", plan: prepared.plan }; + if (prepared.status === "satisfied") return { status: "satisfied", plan: prepared.plan }; + const result = await applyIOSFileTransaction(prepared.mutations, [ + async () => validatePreparedIOSAppleEntitlement(prepared), + ]); + return { status: result.status, plan: prepared.plan }; +} diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts new file mode 100644 index 00000000..921f1828 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -0,0 +1,732 @@ +import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { cp, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { inspectIOSProject } from "./inspect.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup, applyIOSRuntimeKeySetup } from "./apply.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { + authFixtureKey, + canonicalSwiftUIFixture, + createIsolatedCLIState, + createUnconfiguredFixture, + developmentPublishableKey, + runCLI, + runCommand, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; + +setDefaultTimeout(15_000); + +describe("clerk init iOS SDK runtime apply", () => { + const captured = useCaptureLog(); + test("does not combine LocalSecrets mutation with new entitlements creation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-entitlements-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); + expect(setup.associatedDomainPlan).toBeUndefined(); + expect(await treeDigest(root)).toEqual(before); + }); + + test("hands off a runtime key without rewriting a fully linked unattributed package graph", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-handoff-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const unattributedProject = (await Bun.file(projectFile).text()) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKitUI;`, + "productName = ClerkKitUI;", + ); + await Bun.write(projectFile, unattributedProject); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const beforeProjectBytes = await Bun.file(projectFile).bytes(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); + expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); + + const key = developmentPublishableKey("unattributed.clerk.example"); + await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan!, key); + + expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toContain(key); + }); + + test("does not bypass AuthView compatibility proof for unattributed Clerk products", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-auth-view-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + + const initialSetup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + }); + await applyIOSPlannedLocalSetup(initialSetup, authFixtureKey); + + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectFile).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const object of Object.values(objects)) { + if (object.isa === "XCRemoteSwiftPackageReference") { + object.requirement = { kind: "exactVersion", version: "1.2.0" }; + } + if ( + object.isa === "XCSwiftPackageProductDependency" && + ["ClerkKit", "ClerkKitUI"].includes(String(object.productName)) + ) { + delete object.package; + } + } + await Bun.write(projectFile, buildPbxProject(project)); + + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("not attributed to a Swift package reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not bypass unattributed-product review when a policy-required UI product is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-missing-ui-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: "core-only", + }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const unattributedProject = (await Bun.file(projectFile).text()).replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ); + await Bun.write(projectFile, unattributedProject); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("Clerk iOS SDK could not be installed automatically"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not bypass a non-attribution package blocker when all required products are linked", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-wrong-package-runtime-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const malformed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replaceAll(`package = ${IOS_FIXTURE_IDS.clerkPackage};`, `package = ${wrongPackageId};`); + await Bun.write(projectFile, malformed); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("verified clerk-ios reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not let an unattributed product hide another product's wrong package", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-mixed-package-runtime-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const mixed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKitUI;`, + `package = ${wrongPackageId}; productName = ClerkKitUI;`, + ); + await Bun.write(projectFile, mixed); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("verified clerk-ios reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks all local writes when a structurally eligible runtime sink fails strict preflight", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-blocked-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("readable XML property-list dictionary"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("a mismatched expected app key blocks before SDK or key mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-relink-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const before = await treeDigest(root); + const expectedKey = developmentPublishableKey("different.clerk.example"); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect(applyIOSPlannedLocalSetup(setup, expectedKey)).rejects.toThrow( + "does not match the linked Clerk application's development key", + ); + + expect(await treeDigest(root)).toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + }); + + test("a requested app with a satisfied sink fails closed without its expected key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-expected-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect(applyIOSPlannedLocalSetup(setup)).rejects.toThrow( + "development publishable key was not available", + ); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("a matching expected app key permits SDK installation regardless of local profile", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-match-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const key = developmentPublishableKey("matching.clerk.example"); + const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); + await Bun.write( + localSecretsPath, + `CLERK_PUBLISHABLE_KEY${key}`, + ); + + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + await applyIOSPlannedLocalSetup(result, key); + + expect(result).toMatchObject({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + expect((await inspectIOSProject(root, { target: "MyApp" })).appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(await Bun.file(localSecretsPath).text()).toContain(key); + expect(JSON.stringify(result)).not.toContain(key); + }); + + test("a LocalSecrets change during SDK validation rolls the project edit back", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-race-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const expectedKey = developmentPublishableKey("verified.clerk.example"); + const concurrentKey = developmentPublishableKey("concurrent.clerk.example"); + const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const plist = (key: string) => + `CLERK_PUBLISHABLE_KEY${key}`; + await Bun.write(localSecretsPath, plist(expectedKey)); + const projectBefore = await Bun.file(projectPath).bytes(); + const entitlementsBefore = await Bun.file(entitlementsPath).bytes(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect( + applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(localSecretsPath, plist(concurrentKey)); + }, + }), + ).rejects.toThrow("SDK change was restored byte-for-byte"); + + expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); + expect(await Bun.file(entitlementsPath).bytes()).toEqual(entitlementsBefore); + expect(await Bun.file(localSecretsPath).text()).toBe(plist(concurrentKey)); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); + }); + + test("an inline key for another application blocks the SDK and source transaction", async () => { + const root = await createUnconfiguredFixture(); + const existingKey = developmentPublishableKey("existing-inline.clerk.example"); + const selectedKey = developmentPublishableKey("selected-inline.clerk.example"); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${existingKey}") + } + + var body: some Scene { + WindowGroup { + Text("Hello") + .environment(Clerk.shared) + } + } +} +`, + ); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + await expect(applyIOSPlannedLocalSetup(setup, selectedKey)).rejects.toThrow( + "belongs to a different Clerk application", + ); + + expect(await treeDigest(root)).toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(selectedKey); + }); + + test("a post-preview Swift edit prevents both source and SDK writes", async () => { + const root = await createUnconfiguredFixture(); + const key = developmentPublishableKey("stale-direct.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write(sourcePath, `${await Bun.file(sourcePath).text()}\n// concurrent edit\n`); + const concurrentTree = await treeDigest(root); + + await expect(applyIOSPlannedLocalSetup(setup, key)).rejects.toThrow( + "Swift app entry source changed after the preview", + ); + + expect(await treeDigest(root)).toEqual(concurrentTree); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "absent", + clerkKitUI: "absent", + }); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("a post-preview entitlements edit prevents both source and SDK writes", async () => { + const root = await createUnconfiguredFixture(); + const key = developmentPublishableKey("stale-entitlements.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const projectBefore = await Bun.file(projectPath).bytes(); + const sourceBefore = await Bun.file(sourcePath).bytes(); + const concurrentEntitlements = (await Bun.file(entitlementsPath).text()).replace( + "", + "\n", + ); + await Bun.write(entitlementsPath, concurrentEntitlements); + + await expect(applyIOSPlannedLocalSetup(setup, key)).rejects.toThrow( + "entitlements file changed after the preview", + ); + + expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); + expect(await Bun.file(sourcePath).bytes()).toEqual(sourceBefore); + expect(await Bun.file(entitlementsPath).text()).toBe(concurrentEntitlements); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "absent", + clerkKitUI: "absent", + }); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("a declined human confirmation leaves the project byte-identical", async () => { + const root = await createUnconfiguredFixture(); + const before = await treeDigest(root); + const confirmation = spyOn(prompts, "confirm").mockResolvedValue(false); + + try { + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: false, + allowDirty: false, + }), + ).rejects.toMatchObject({ name: "UserAbortError" }); + } finally { + confirmation.mockRestore(); + } + + expect(await treeDigest(root)).toEqual(before); + }); + + test("dry-run advertises the action but remains byte-for-byte read-only", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["init", "--dry-run", "--json"], configDir); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "install-clerk-sdk", + status: "required", + automatable: true, + }), + ); + expect(await treeDigest(root)).toEqual(before); + }); + + test("dry-run advertises safe missing-entitlements creation without writing", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["init", "--dry-run", "--json"], configDir); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "add-associated-domain", + status: "required", + automatable: true, + }), + ); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + blockers: [], + }); + expect(JSON.stringify(output)).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(before); + }); + + test("requires --allow-dirty for the planned project file and preserves its changes", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + const dirtyProject = (await Bun.file(projectFile).text()).replaceAll( + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;", + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp.local;", + ); + await Bun.write(projectFile, dirtyProject); + + const blocked = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + expect(blocked.exitCode).toBe(1); + expect(`${blocked.stdout}\n${blocked.stderr}`).toContain("--allow-dirty"); + expect(await Bun.file(projectFile).text()).toBe(dirtyProject); + + const applied = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--allow-dirty", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + ], + configDir, + ); + expect(applied.exitCode).toBe(0); + expect(await Bun.file(projectFile).text()).toContain( + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp.local;", + ); + }); + + test("requires --allow-dirty for entitlements and preserves unrelated local content", async () => { + const root = await createUnconfiguredFixture(); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + const localComment = ""; + const dirtyEntitlements = (await Bun.file(entitlementsPath).text()).replace( + "", + `\n${localComment}`, + ); + await Bun.write(entitlementsPath, dirtyEntitlements); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("MyApp/MyApp.entitlements already has local changes"); + expect(await Bun.file(entitlementsPath).text()).toBe(dirtyEntitlements); + + const key = developmentPublishableKey("dirty-entitlements.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + }); + await applyIOSPlannedLocalSetup(setup, key); + + const appliedEntitlements = await Bun.file(entitlementsPath).text(); + expect(appliedEntitlements).toContain(localComment); + expect(appliedEntitlements).toContain("webcredentials:dirty-entitlements.clerk.example"); + }); + + test("dirty-checks .gitignore when crash-safe key staging needs a guard", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-dirty-ignore-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n# local change\n"); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow(".gitignore already has local changes"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("fails closed when Git cannot determine the selected project file status", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + await Bun.write(join(root, ".git", "index"), "not a valid Git index"); + const before = await Bun.file(projectFile).text(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("could not be verified"); + expect(await Bun.file(projectFile).text()).toBe(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts new file mode 100644 index 00000000..544bb346 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts @@ -0,0 +1,277 @@ +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, + }; +} + +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 00000000..0bc0c898 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -0,0 +1,768 @@ +import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { cp, mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { inspectIOSProject } from "./inspect.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { + addStarterContentViewToFixture, + authFixtureKey, + canonicalSwiftUIFixture, + createCustomFlowWithStarterContent, + createIsolatedCLIState, + createUnconfiguredFixture, + currentAppleConnection, + resetAppleConfiguration, + runCLI, + runCommand, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; + +setDefaultTimeout(15_000); + +describe("clerk init iOS SDK apply", () => { + const captured = useCaptureLog(); + + test("applies the explicit prebuilt AuthView opt-in in the aggregate Swift transaction", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-apply-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + const beforeApp = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + expect(setup.unverifiedAppIdPrefixSuggestion).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + expect(setup.prebuiltAuthPlan?.status).toBe("ready"); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const source = await Bun.file(join(root, "MyApp", "ContentView.swift")).text(); + expect(source).toContain("UserButton(signedOutContent:"); + expect(source).toContain('Button("Sign up")'); + expect(source).toContain("@State private var authIsPresented = false"); + expect(source).toContain(".prefetchClerkImages()"); + expect(source).toContain(".sheet(isPresented: $authIsPresented)"); + expect(source).toContain("AuthView()"); + expect(source).not.toContain("@Environment"); + expect(source).not.toContain(".onOpenURL"); + expect(source).not.toContain("clerk.auth.events"); + expect(source).not.toContain("clerk.session?.tasks"); + expect(source).not.toContain(".alert("); + expect(source).not.toContain("#Preview"); + expect(await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text()).not.toBe(beforeApp); + + const firstDigest = await treeDigest(root); + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + expect(rerun.prebuiltAuthPlan?.status).toBe("satisfied"); + await applyIOSPlannedLocalSetup(rerun, authFixtureKey); + expect(await treeDigest(root)).toEqual(firstDigest); + expect(`${captured.out}\n${captured.err}`).not.toContain(authFixtureKey); + }); + + test("blocks an explicit prebuilt AuthView below iOS 17 before the aggregate write", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-ios16-apply-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await Bun.write( + projectPath, + (await Bun.file(projectPath).text()).replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0", + "IPHONEOS_DEPLOYMENT_TARGET = 16.4", + ), + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("require iOS 17.0 or newer"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("links ClerkKitUI when a custom-flow target explicitly opts into AuthView", async () => { + const root = await createCustomFlowWithStarterContent(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + + expect(setup.prebuiltAuthPlan?.status).toBe("ready"); + expect(setup.directConfigPlan).toMatchObject({ + status: "ready", + changes: { + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(setup.requiresDevelopmentKey).toBe(true); + expect(setup.sdkInstallPlan?.products).toEqual(["ClerkKit", "ClerkKitUI"]); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).toContain("AuthView()"); + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + }); + + test("links ClerkKitUI when a custom-flow target accepts the AuthView prompt", async () => { + const root = await createCustomFlowWithStarterContent(); + const confirmation = spyOn(prompts, "confirm").mockImplementation(async ({ message }) => { + if (message.startsWith("Add ClerkKitUI's prebuilt authentication UI")) return true; + if (message.startsWith("Enable native Sign in with Apple")) return false; + if (message === "Apply these local iOS changes?") return true; + throw new Error(`Unexpected confirmation: ${message}`); + }); + + try { + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: false, + allowDirty: true, + }); + + expect(setup.prebuiltAuthRequested).toBe(true); + expect(setup.directConfigPlan?.status).toBe("ready"); + expect(setup.sdkInstallPlan?.products).toEqual(["ClerkKit", "ClerkKitUI"]); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + } finally { + confirmation.mockRestore(); + } + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages.clerkKitUI).toBe("linked"); + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + }); + + test("refuses a ProcessInfo compatibility path without proven SwiftUI environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-process-info-auth-view-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure( + publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "" + ) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("Clerk.shared is not proven in the shipping SwiftUI root environment"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("refuses a LocalSecrets compatibility path without proven SwiftUI environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-auth-view-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await addStarterContentViewToFixture(root); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + appPath, + (await Bun.file(appPath).text()).replace("import ClerkKitUI\n", "").replace( + `AuthView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } }`, + "ContentView()", + ), + ); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${authFixtureKey}`, + ); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("Clerk.shared is not proven in the shipping SwiftUI root environment"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("revalidates AuthView runtime prerequisites before committing any planned file", async () => { + const root = await createCustomFlowWithStarterContent(); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + const before = await treeDigest(root); + + await expect( + applyIOSPlannedLocalSetup({ + ...setup, + directConfigPlan: undefined, + requiresDevelopmentKey: false, + }), + ).rejects.toThrow("no longer proves its Clerk runtime prerequisites"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("creates an iOS-only entitlements file in the aggregate SDK and Swift transaction", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Create MyApp/MyApp.entitlements with the linked development"); + expect(output).not.toContain(authFixtureKey); + const source = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(source).toContain("import ClerkKit"); + expect(source).toContain("Clerk.configure(publishableKey:"); + expect(source).toContain(".environment(Clerk.shared)"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(entitlements).not.toContain("application-identifier"); + expect(entitlements).not.toContain("com.apple.developer.applesignin"); + + const archive = parsePbxProject( + await Bun.file(join(root, "MyApp.xcodeproj", "project.pbxproj")).text(), + ) as unknown as { objects: PbxObjects }; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = archive.objects[id]!.buildSettings as Record; + expect(settings.CODE_SIGN_ENTITLEMENTS).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBe("MyApp/MyApp.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBe( + "MyApp/MyApp.entitlements", + ); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.mac.entitlements"); + } + + const digest = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("rolls back SDK, Swift, and a newly created entitlements file together", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const before = await treeDigest(root); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect( + applyIOSPlannedLocalSetup(setup, authFixtureKey, { + beforePostWriteValidation: () => { + throw new Error("injected aggregate validation failure"); + }, + }), + ).rejects.toThrow("restored byte-for-byte"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "MyApp.entitlements")).exists()).toBe(false); + }); + + test("explicitly opts into native Apple without requesting hosted Apple credentials", async () => { + resetAppleConfiguration({ + enabled: false, + authenticatable: true, + client_id: "existing.web.service", + client_secret: "HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE", + }); + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + "--sign-in-with-apple", + ], + configDir, + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Native Sign in with Apple enabled in Clerk"); + expect(output).not.toContain(authFixtureKey); + expect(output).not.toContain("HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("com.apple.developer.applesignin"); + expect(entitlements).toContain("Default"); + expect(currentAppleConnection()).toEqual({ + enabled: true, + authenticatable: true, + bundle_id: "com.example.MyApp", + client_id: "existing.web.service", + client_secret: "HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE", + }); + + const digest = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--sign-in-with-apple"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("does not treat an existing Apple entitlement plus --yes as Clerk Apple opt-in", async () => { + resetAppleConfiguration({ enabled: false, authenticatable: true }); + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const optedIn = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + "--sign-in-with-apple", + ], + configDir, + ); + expect(optedIn.exitCode).toBe(0); + expect(currentAppleConnection()).toMatchObject({ enabled: true, authenticatable: true }); + + // Keep the local entitlement as detection evidence while simulating a + // Clerk connection that has not been opted into for this invocation. + resetAppleConfiguration({ enabled: false, authenticatable: true }); + const withoutOptIn = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(withoutOptIn.exitCode).toBe(0); + expect(currentAppleConnection()).toEqual({ enabled: false, authenticatable: true }); + expect(`${withoutOptIn.stdout}\n${withoutOptIn.stderr}`).not.toContain( + "Native Sign in with Apple enabled in Clerk", + ); + }); + + test("links ClerkKit and ClerkKitUI to a clean target and is byte-idempotent", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "ClerkKit and ClerkKitUI linked to MyApp", + ); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(authFixtureKey); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const target = inspection.appTargets.find((candidate) => candidate.name === "MyApp"); + expect(target?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(inspection.projects[0]?.packages[0]).toMatchObject({ + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + isClerk: true, + }); + const source = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(source).toContain("import ClerkKit"); + expect(source.match(/Clerk\.configure\(publishableKey:/g)).toHaveLength(1); + expect(source).toContain(".environment(Clerk.shared)"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(`${result.stdout}\n${result.stderr}`).toContain("Clerk Associated Domain added"); + expect(await Bun.file(join(root, ".env")).exists()).toBe(false); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).exists()).toBe(false); + + const afterFirstRun = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(afterFirstRun); + }); + + test("adds ClerkKitUI to a source-blank target left core-only by an earlier setup", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-core-only-migration-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: "core-only", + complete: false, + includeKey: false, + }); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "ClerkKit and ClerkKitUI linked to MyApp", + ); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test("preserves ClerkKit-only installation for an existing custom-flow source", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "absent", + }); + }); + + test("does not choose products when Swift source membership is incomplete", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectFile).text(); + const danglingBuildFile = "FEFEFEFEFEFEFEFEFEFEFEFE"; + await Bun.write( + projectFile, + project.replace( + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, );`, + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, ${danglingBuildFile}, );`, + ), + ); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("could not be inspected completely"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("links both products only to a fresh explicitly selected second target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-second-target-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false, secondTarget: true }); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "AdminApp", "--app-id-prefix", "ADMIN12345"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const inspection = await inspectIOSProject(root); + const primary = inspection.appTargets.find((target) => target.name === "MyApp"); + const selected = inspection.appTargets.find((target) => target.name === "AdminApp"); + expect(primary?.packages).toMatchObject({ clerkKit: "absent", clerkKitUI: "absent" }); + expect(selected?.packages).toMatchObject({ clerkKit: "linked", clerkKitUI: "linked" }); + }); + + test("validates an apparently linked graph before treating it as a no-op", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-wrong-package-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const malformed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replaceAll(`package = ${IOS_FIXTURE_IDS.clerkPackage};`, `package = ${wrongPackageId};`); + await Bun.write(projectFile, malformed); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("verified clerk-ios reference"); + expect(await Bun.file(projectFile).text()).toBe(malformed); + }); + + test("agent mode requires explicit --yes before changing the Xcode project", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["--mode", "agent", "init", "--target", "MyApp"], configDir); + + expect(result.exitCode).toBe(2); + expect(`${result.stdout}\n${result.stderr}`).toContain("requires explicit consent"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("accepts a clean project when Git canonicalizes an aliased project path", async () => { + const root = await mkdtemp(join("/tmp", "clerk-ios-apply-alias-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "config", "user.name", "Clerk CLI Test"]); + await runCommand(root, ["git", "config", "user.email", "cli-test@clerk.test"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, ["git", "commit", "-m", "fixture"]); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test("an already-linked generated project is not source-edited", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-generated-satisfied-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { generated: "xcodegen" }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["--mode", "agent", "init", "--target", "MyApp"], configDir); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("XcodeGen project"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("an already-linked SDK returns a read-only runtime verification without prompting or writing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + entitlementsPath, + (await Bun.file(entitlementsPath).text()).replace( + "webcredentials:clerk.example.test", + "webcredentials:native.clerk.example", + ), + ); + const before = await treeDigest(root); + const confirmation = spyOn(prompts, "confirm").mockResolvedValue(false); + + try { + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: true, + allowDirty: false, + }); + + expect(result.runtimeKeyVerificationPlan).toMatchObject({ + status: "ready", + localSecretsPath: "MyApp/LocalSecrets.plist", + }); + expect(confirmation).not.toHaveBeenCalled(); + expect(await treeDigest(root)).toEqual(before); + } finally { + confirmation.mockRestore(); + } + }); + + test("pre-authorizes a proven runtime sink without fetching or writing its key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-preflight-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const before = await treeDigest(root); + + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(result.runtimeKeyPlan).toMatchObject({ + status: "ready", + localSecretsPath: "MyApp/LocalSecrets.plist", + }); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify(result)).not.toContain("pk_live_"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts new file mode 100644 index 00000000..e0570005 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -0,0 +1,1501 @@ +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, throwUsageError, throwUserAbort } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { + planIOSSDKInstall, + prepareIOSSDKInstallMutation, + validateIOSSDKInstallPostcondition, + type IOSSDKInstallPlan, + type PreparedIOSSDKInstallMutation, +} from "./install-sdk.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./plan.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./products.ts"; +import { + planIOSDirectConfig, + prepareIOSDirectConfigMutation, + validatePreparedIOSDirectConfig, + type IOSDirectConfigPlan, + type IOSDirectConfigPreparedMutation, +} from "./direct-config.ts"; +import { + applyIOSExistingFileTransaction, + applyIOSFileTransaction, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + applyIOSRuntimeKey, + planIOSRuntimeKey, + planIOSRuntimeKeyVerification, + verifyIOSRuntimeKey, + type IOSRuntimeKeyPlan, + type IOSRuntimeKeyVerificationPlan, +} from "./runtime-key.ts"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, + type IOSAssociatedDomainPlan, + type PreparedIOSAssociatedDomainMutation, +} from "./associated-domain.ts"; +import { + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, + type IOSAppleEntitlementPlan, + type PreparedIOSAppleEntitlementMutation, +} from "./apple-entitlement.ts"; +import { + buildIOSNativeReadinessAudit, + suggestAppIdPrefixFromDevelopmentTeam, + type IOSNativeReadinessAudit, + type IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; +import { + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, + validatePreparedIOSPrebuiltAuth, + type IOSPrebuiltAuthPlan, + type PreparedIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; + +export interface ApplyIOSLocalSetupOptions { + root: string; + target?: string; + yes: boolean; + agent: boolean; + allowDirty: boolean; + /** Explicit native Apple opt-in. Undefined allows a human prompt. */ + signInWithApple?: boolean; + /** Explicit prebuilt AuthView opt-in. Undefined allows a default-off human prompt. */ + prebuiltAuthUI?: boolean; +} + +/** Read-only SDK compatibility planner shared by the AuthView dry-run path. */ +export async function planIOSPrebuiltAuthSDKCompatibility(options: { + root: string; + projectPath: string; + targetId: string; +}): Promise { + return planIOSSDKInstall({ + ...options, + includeClerkKitUI: true, + requirePrebuiltAuthCompatibility: true, + }); +} + +export interface IOSLocalSetupResult { + targetName: string; + /** Redacted local identity used to audit the linked instance after authentication. */ + nativeReadiness: IOSNativeReadinessAudit; + /** Human-only Xcode signing-team suggestion; never treated as proven prefix evidence. */ + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + sdkInstallPlan?: IOSSDKInstallPlan; + /** Fresh/default direct Swift configuration or existing inline verification. */ + directConfigPlan?: IOSDirectConfigPlan; + /** Pre-authorized, redacted plan whose key is resolved only after app linking. */ + runtimeKeyPlan?: IOSRuntimeKeyPlan; + /** Read-only proof for comparing an already configured sink after app linking. */ + runtimeKeyVerificationPlan?: IOSRuntimeKeyVerificationPlan; + /** Existing entitlements files that can receive the exact linked webcredentials host. */ + associatedDomainPlan?: IOSAssociatedDomainPlan; + /** Selected-target Sign in with Apple entitlement setup or verification. */ + appleEntitlementPlan?: IOSAppleEntitlementPlan; + /** Optional prebuilt AuthView source setup or exact generated-flow verification. */ + prebuiltAuthPlan?: IOSPrebuiltAuthPlan; + /** + * Pre-authorized local Apple capability candidate for the selected AuthView flow. + * It is applied only when a later environment audit proves Apple is enabled. + */ + prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; + /** Explicit flag or AuthView-specific human confirmation; never inferred from --yes. */ + prebuiltAuthRequested: boolean; + /** Explicitly selected or byte-identical generated AuthView flow present on a rerun. */ + prebuiltAuthActive: boolean; + /** Explicit flag or Apple-specific human confirmation; never inferred from --yes. */ + nativeAppleRequested: boolean; + /** Authentication must return an exact app ID and development key before commit. */ + requiresLinkedApp: boolean; + /** The approved local transaction consumes the linked development publishable key. */ + requiresDevelopmentKey: boolean; + /** An existing runtime value must not be paired with an auto-created agent app. */ + verifiesExistingKey: boolean; +} + +/** @internal Test-only hook used to prove aggregate post-write rollback. */ +export interface ApplyIOSPlannedLocalSetupOptions { + beforePostWriteValidation?: () => void | Promise; +} + +type GitPathState = "clean" | "dirty" | "not-repository" | "unknown"; +const GIT_PATH_STATE_TIMEOUT_MS = 5_000; + +async function hasGitMarkerInAncestors(start: string): Promise { + let directory = resolve(start); + while (true) { + try { + await lstat(resolve(directory, ".git")); + return true; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(directory); + if (parent === directory) return false; + directory = parent; + } +} + +async function gitPathState(absolutePath: string): Promise { + const projectDirectory = dirname(absolutePath); + try { + const repository = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + cwd: projectDirectory, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + const repositoryRoot = (await new Response(repository.stdout).text()).trim(); + const repositoryExitCode = await repository.exited; + if (repository.signalCode != null) return "unknown"; + if (repositoryExitCode !== 0) { + return (await hasGitMarkerInAncestors(projectDirectory)) ? "unknown" : "not-repository"; + } + if (repositoryRoot === "") return "unknown"; + + const canonicalRepositoryRoot = await realpath(repositoryRoot); + let canonicalAbsolutePath: string; + try { + canonicalAbsolutePath = await realpath(absolutePath); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + return "unknown"; + } + canonicalAbsolutePath = resolve( + await realpath(dirname(absolutePath)), + basename(absolutePath), + ); + } + const path = relative(canonicalRepositoryRoot, canonicalAbsolutePath); + if (path === "" || path === ".." || path.startsWith(`..${sep}`)) return "unknown"; + + const status = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", path], + { + cwd: canonicalRepositoryRoot, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }, + ); + const output = await new Response(status.stdout).text(); + const statusExitCode = await status.exited; + if (status.signalCode != null || statusExitCode !== 0) return "unknown"; + return output.trim() === "" ? "clean" : "dirty"; + } catch { + return "unknown"; + } +} + +function formatProducts(products: string[]): string { + if (products.length === 1) return products[0]!; + return `${products.slice(0, -1).join(", ")} and ${products.at(-1)}`; +} + +function directConfigNeedsWrite(plan: IOSDirectConfigPlan | undefined): boolean { + const changes = plan?.changes; + return ( + plan?.status === "ready" && + changes != null && + (changes.clerkKitImport === "insert" || + changes.configuration !== "verify-existing" || + changes.environment === "insert") + ); +} + +function associatedDomainNeedsWrite( + plan: IOSAssociatedDomainPlan | undefined, +): plan is IOSAssociatedDomainPlan { + return plan?.status === "ready"; +} + +function blockerList(blockers: Array<{ message: string }>): string { + return blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); +} + +export function planIOSPrebuiltAuthRuntimeBlockers( + inspection: Awaited>, + directConfigPlan: IOSDirectConfigPlan | undefined, + runtimeKeyPlan: IOSRuntimeKeyPlan | undefined, +): string[] { + const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan, runtimeKeyPlan }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const directConfigurationReady = + directConfigPlan?.status === "ready" && configureStep?.automatable === true; + const runtimeKeyConfigurationReady = + runtimeKeyPlan?.status === "ready" && configureStep?.automatable === true; + const directEnvironmentReady = + directConfigPlan?.status === "ready" && + (directConfigPlan.changes?.environment === "insert" || + directConfigPlan.changes?.environment === "satisfied"); + const blockers: string[] = []; + + if ( + configureStep?.status !== "satisfied" && + !directConfigurationReady && + !runtimeKeyConfigurationReady + ) { + blockers.push( + "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", + ); + } + if (environmentStep?.status !== "satisfied" && !directEnvironmentReady) { + blockers.push( + "Clerk.shared is not proven in the shipping SwiftUI root environment, and the existing runtime abstraction cannot be rewritten safely.", + ); + } + + return blockers; +} + +async function validatePrebuiltAuthRuntimePostcondition( + setup: IOSLocalSetupResult, + allowPendingRuntimeKey: boolean, +): Promise { + if (!setup.prebuiltAuthActive) return true; + if (setup.nativeReadiness.target.status !== "selected") return false; + const target = setup.nativeReadiness.target; + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: target.targetId, + }); + const setupPlan = buildIOSSetupPlan(inspection, { + runtimeKeyPlan: allowPendingRuntimeKey ? setup.runtimeKeyPlan : undefined, + }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const configurationReady = + configureStep?.status === "satisfied" || + (allowPendingRuntimeKey && + setup.runtimeKeyPlan?.status === "ready" && + configureStep?.automatable === true); + + return configurationReady && environmentStep?.status === "satisfied"; +} + +/** + * Inspects, previews, and authorizes the local iOS setup without writing it. + * The returned redacted plans are prepared again and committed only after an + * exact Clerk application and development publishable key have been resolved. + */ +export async function applyIOSLocalSetup( + options: ApplyIOSLocalSetupOptions, +): Promise { + const inspection = await withSpinner("Inspecting Xcode project...", async () => + inspectIOSProject(options.root, { target: options.target }), + ); + const selection = inspection.selection; + if (selection.state !== "selected") { + if (selection.state === "ambiguous") { + const candidates = selection.candidates + .map( + (candidate) => + `${candidate.targetName} (${candidate.targetId}, ${candidate.projectPath})`, + ) + .join(", "); + throwUsageError( + `More than one iOS application target is eligible: ${candidates}. Rerun with --target ; if IDs collide across copied projects, run the command from the intended project's directory.`, + ); + } + if (selection.state === "not-found") { + throwUsageError( + `The iOS target "${selection.requested}" was not found. Available targets: ${selection.candidates.join(", ") || "none"}.`, + ); + } + throw new CliError("No usable iOS application target was found."); + } + + const selectedTarget = inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); + if (!selectedTarget) { + throw new CliError("The selected iOS target could not be resolved safely."); + } + const unverifiedAppIdPrefixSuggestion = suggestAppIdPrefixFromDevelopmentTeam(selectedTarget); + const productDecision = clerkKitUIInstallDecision(selectedTarget); + if (productDecision === "unknown") { + throw new CliError( + "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.", + ); + } + const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }); + let prebuiltAuthRequested = options.prebuiltAuthUI === true; + if ( + !prebuiltAuthRequested && + options.prebuiltAuthUI == null && + inspectedPrebuiltAuthPlan.status === "ready" && + !options.agent && + !options.yes + ) { + prebuiltAuthRequested = await confirm({ + message: `Add ClerkKitUI's prebuilt authentication UI to ${selection.targetName}?`, + default: false, + }); + } + if (prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") { + throw new CliError( + `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList(inspectedPrebuiltAuthPlan.blockers)}`, + ); + } + const prebuiltAuthActive = + prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"; + const prebuiltAuthPlan = prebuiltAuthActive ? inspectedPrebuiltAuthPlan : undefined; + + // A source-proven custom flow remains core-only by default, but an explicit + // or interactive AuthView selection must link the product that generated + // source imports before the aggregate transaction is authorized. + const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; + + const installPlan = await planIOSSDKInstall({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + includeClerkKitUI, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, + }); + + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (candidate) => candidate.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + configureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); + const plannedRuntimeKey = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const runtimeKeyPlan = plannedRuntimeKey?.status === "ready" ? plannedRuntimeKey : undefined; + const hasSatisfiedLocalRuntimeSink = + configureStep?.status === "satisfied" && + inspection.localPublishableKey.source != null && + selectedTarget.runtimeKeySinks.some( + (sink) => sink.path === inspection.localPublishableKey.source, + ); + const plannedRuntimeKeyVerification = hasSatisfiedLocalRuntimeSink + ? await planIOSRuntimeKeyVerification({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const runtimeKeyVerificationPlan = + plannedRuntimeKeyVerification?.status === "ready" ? plannedRuntimeKeyVerification : undefined; + const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ); + const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => + source.endsWith(".xcscheme"), + ); + const shouldPlanDirectConfig = shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ); + const directConfigPlan = shouldPlanDirectConfig + ? await planIOSDirectConfig({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }) + : undefined; + if ( + directConfigNeedsWrite(directConfigPlan) && + prebuiltAuthPlan?.status === "ready" && + directConfigPlan?.sourcePath === prebuiltAuthPlan.sourcePath + ) { + throw new CliError( + "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.", + ); + } + const plannedAssociatedDomain = await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + // A LocalSecrets write is a specialized secret transaction that cannot + // yet share rollback ownership with a newly created entitlements file. + allowMissingEntitlementsCreation: runtimeKeyPlan == null, + }); + // Associated Domains is an independent additive improvement. Unsupported + // or ambiguous entitlements must not prevent the already-proven SDK/source + // setup; those cases remain an actionable manual step in the final plan. + const associatedDomainPlan = + plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { + associatedDomainPlan: plannedAssociatedDomain, + }); + if ( + nativeReadiness.target.status !== "selected" || + nativeReadiness.target.bundleIdentifier.status !== "resolved" + ) { + throw new CliError( + "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.", + ); + } + const hasLocalAppleEntitlement = selectedTarget.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + let nativeAppleRequested = options.signInWithApple === true; + if (!nativeAppleRequested && options.signInWithApple == null && !options.agent && !options.yes) { + nativeAppleRequested = await confirm({ + message: `Enable native Sign in with Apple for ${nativeReadiness.target.bundleIdentifier.value}?`, + default: false, + }); + } + const inspectedAppleEntitlementPlan = + nativeAppleRequested || hasLocalAppleEntitlement || prebuiltAuthActive + ? await planIOSAppleEntitlement({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + // New entitlements creation cannot be rolled back through the + // specialized LocalSecrets transaction. + allowMissingEntitlementsCreation: runtimeKeyPlan == null, + }) + : undefined; + // Existing entitlement evidence remains available for a read-only satisfied + // verification, but incomplete local Apple setup is never completed unless + // this invocation explicitly opted into the strategy. + const appleEntitlementPlan = nativeAppleRequested + ? inspectedAppleEntitlementPlan + : inspectedAppleEntitlementPlan?.status === "satisfied" + ? inspectedAppleEntitlementPlan + : undefined; + const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive + ? inspectedAppleEntitlementPlan + : undefined; + if (appleEntitlementPlan?.status === "blocked") { + throw new CliError( + `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList(appleEntitlementPlan.blockers)}`, + ); + } + const reviewOnlyUnattributedInstall = + !prebuiltAuthActive && + installPlan.requirePrebuiltAuthCompatibility !== true && + installPlan.status === "blocked" && + installPlan.blockers.length > 0 && + installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && + installPlan.products.every((product) => + product === "ClerkKit" + ? selectedTarget.packages.clerkKit === "linked" + : selectedTarget.packages.clerkKitUI === "linked", + ); + const sdkInstallPlan = reviewOnlyUnattributedInstall ? undefined : installPlan; + + if (plannedRuntimeKeyVerification?.status === "blocked") { + throw new CliError( + `The existing iOS runtime publishable key could not be verified safely. No local files were changed:\n${blockerList(plannedRuntimeKeyVerification.blockers)}`, + ); + } + if (plannedRuntimeKey?.status === "blocked") { + throw new CliError( + `The development publishable key could not be wired safely. No local files were changed:\n${blockerList(plannedRuntimeKey.blockers)}`, + ); + } + if (directConfigPlan?.status === "blocked") { + throw new CliError( + `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList(directConfigPlan.blockers)}`, + ); + } + if ( + (productDecision === "prebuilt" || prebuiltAuthActive) && + selectedTarget.swift.configureCalls.length === 0 && + !directConfigPlan && + !runtimeKeyPlan + ) { + const reason = hasEnabledSchemeKey + ? "an enabled Run-scheme publishable key already indicates a custom runtime configuration" + : selectedTarget.runtimeKeySinks.length > 0 + ? "a target-owned LocalSecrets.plist exists without a proven loader" + : "the selected runtime configuration could not be proven"; + throw new CliError( + `The fresh SwiftUI target was not edited because ${reason}. Resolve that setup or configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.`, + ); + } + if (hasLocalSecretsConfigure && !runtimeKeyPlan && !runtimeKeyVerificationPlan) { + throw new CliError( + "An existing LocalSecrets-based Clerk configuration was found, but its selected-target runtime sink could not be proven. No local files were changed; repair or confirm that compatibility path manually.", + ); + } + if (prebuiltAuthActive) { + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( + inspection, + directConfigPlan, + runtimeKeyPlan, + ); + if (runtimeBlockers.length > 0) { + throw new CliError( + `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${runtimeBlockers + .map((message) => ` • ${message}`) + .join("\n")}`, + ); + } + } + + if (installPlan.status === "satisfied") { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}.`, + ), + ); + } + if (reviewOnlyUnattributedInstall) { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}, but package attribution is not represented in this project graph. The existing Xcode package graph will be left unchanged.`, + ), + ); + } else if (installPlan.status === "blocked") { + throw new CliError( + `The Clerk iOS SDK could not be installed automatically:\n${blockerList(installPlan.blockers)}`, + ); + } + const plannedPaths: Array<{ absolutePath: string; displayPath: string }> = []; + if (installPlan.status === "ready") { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + if (runtimeKeyPlan?.localSecretsPath) { + plannedPaths.push({ + absolutePath: resolve(options.root, runtimeKeyPlan.localSecretsPath), + displayPath: runtimeKeyPlan.localSecretsPath, + }); + } + const changesGitignore = runtimeKeyPlan?.changesGitignore === true; + if (changesGitignore && runtimeKeyPlan?.gitignorePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, runtimeKeyPlan.gitignorePath), + displayPath: runtimeKeyPlan.gitignorePath, + }); + } + if (directConfigNeedsWrite(directConfigPlan) && directConfigPlan?.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, directConfigPlan.sourcePath), + displayPath: directConfigPlan.sourcePath, + }); + } + if (prebuiltAuthPlan?.status === "ready" && prebuiltAuthPlan.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, prebuiltAuthPlan.sourcePath), + displayPath: prebuiltAuthPlan.sourcePath, + }); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of associatedDomainPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (appleEntitlementPlan?.status === "ready") { + if (appleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of appleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (!options.allowDirty) { + const uniquePaths = [ + ...new Map(plannedPaths.map((path) => [path.absolutePath, path])).values(), + ]; + for (const path of uniquePaths) { + const state = await gitPathState(path.absolutePath); + if (state === "dirty") { + throw new CliError( + `${path.displayPath} already has local changes. Commit or stash them, or rerun with --allow-dirty to preserve and build on those exact bytes.`, + ); + } + if (state === "unknown") { + throw new CliError( + `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.`, + ); + } + } + } + + const hasLocalWrites = + installPlan.status === "ready" || + runtimeKeyPlan != null || + directConfigNeedsWrite(directConfigPlan) || + prebuiltAuthPlan?.status === "ready" || + associatedDomainNeedsWrite(associatedDomainPlan) || + appleEntitlementPlan?.status === "ready" || + prebuiltAuthAppleEntitlementPlan?.status === "ready"; + if (hasLocalWrites) { + log.info("\nclerk init will make the following local iOS changes:\n"); + } else if ( + directConfigPlan || + runtimeKeyVerificationPlan || + appleEntitlementPlan || + prebuiltAuthPlan + ) { + log.info("\nclerk init will perform the following read-only iOS verification:\n"); + } + if (installPlan.status === "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + for (const action of installPlan.actions) log.info(` ${action}`); + } + if (runtimeKeyPlan) { + if (changesGitignore && runtimeKeyPlan.gitignorePath) { + const operation = runtimeKeyPlan.expectedGitignoreHash == null ? "CREATE" : "MODIFY"; + log.info(` ${yellow(operation)} ${runtimeKeyPlan.gitignorePath}`); + } + log.info(` ${yellow("MODIFY")} ${runtimeKeyPlan.localSecretsPath}`); + for (const action of runtimeKeyPlan.actions) log.info(` ${action}`); + log.info( + dim( + " The linked development publishable key will be fetched after authentication and will never be printed.", + ), + ); + } + if (directConfigPlan) { + const operation = directConfigNeedsWrite(directConfigPlan) ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${directConfigPlan.sourcePath}`); + for (const action of directConfigPlan.actions) log.info(` ${action}`); + log.info( + dim( + " The linked development publishable key will remain in memory and is redacted from the preview and command output.", + ), + ); + } + if (prebuiltAuthPlan) { + const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${prebuiltAuthPlan.sourcePath}`); + for (const action of prebuiltAuthPlan.actions) log.info(` ${action}`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings && installPlan.status !== "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + for (const action of associatedDomainPlan.actions) log.info(` ${action}`); + if (associatedDomainPlan.requiresPublishableKey) { + log.info( + dim( + " The exact linked development host will be resolved after authentication and is redacted from this preview.", + ), + ); + } + } + if (appleEntitlementPlan?.status === "ready") { + const alreadyPreviewedEntitlements = new Set( + associatedDomainNeedsWrite(associatedDomainPlan) + ? associatedDomainPlan.files.map((file) => file.path) + : [], + ); + if ( + appleEntitlementPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of appleEntitlementPlan.files) { + if (!alreadyPreviewedEntitlements.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of appleEntitlementPlan.actions) log.info(` ${action}`); + } else if (appleEntitlementPlan?.status === "satisfied") { + log.info(dim("\n The selected target already has the native Sign in with Apple entitlement.")); + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + log.info( + dim( + "\n Conditional AuthView capability change (only if Apple is enabled for the linked instance):", + ), + ); + const alreadyPreviewedPaths = new Set(); + if (installPlan.status === "ready") { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); + } + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + const projectFile = `${selection.projectPath}/project.pbxproj`; + if (!alreadyPreviewedPaths.has(projectFile)) { + log.info(` ${yellow("MODIFY")} ${projectFile}`); + } + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + if (!alreadyPreviewedPaths.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of prebuiltAuthAppleEntitlementPlan.actions) { + log.info(` If Apple is enabled: ${action}`); + } + } + if (prebuiltAuthActive) { + log.info( + dim( + "\n After authentication, clerk init will inspect the methods available to AuthView. If Apple is enabled for this instance, it will add or verify the required local Sign in with Apple entitlement without enabling or changing the Clerk Apple connection.", + ), + ); + } + if (installPlan.status === "ready") { + log.info(dim("\n Package resolution and xcodebuild will not run.")); + } + log.info( + dim( + nativeAppleRequested + ? "\n After authentication, clerk init will inspect Native API, iOS registration, and the native Apple connection before separately previewing additive remote changes." + : "\n After authentication, clerk init will inspect Native API and iOS registration state and separately preview any additive remote changes.", + ), + ); + log.blank(); + + if (hasLocalWrites && options.agent && !options.yes) { + throwUsageError( + "Changing an Xcode project in agent mode requires explicit consent. Review `clerk init --dry-run`, then rerun `clerk init --yes`.", + ); + } + if (hasLocalWrites && !options.yes) { + const proceed = await confirm({ message: "Apply these local iOS changes?", default: false }); + if (!proceed) throwUserAbort(); + } + + return { + targetName: selection.targetName, + nativeReadiness, + ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), + sdkInstallPlan, + directConfigPlan, + runtimeKeyPlan, + runtimeKeyVerificationPlan, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthPlan, + prebuiltAuthAppleEntitlementPlan, + prebuiltAuthRequested, + prebuiltAuthActive, + nativeAppleRequested, + requiresLinkedApp: true, + requiresDevelopmentKey: + directConfigPlan != null || + runtimeKeyPlan != null || + runtimeKeyVerificationPlan != null || + associatedDomainPlan?.requiresPublishableKey === true, + verifiesExistingKey: + directConfigPlan?.changes?.configuration === "verify-existing" || + runtimeKeyVerificationPlan != null, + }; +} + +function directFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function prebuiltAuthFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function reverseFileMutation(mutation: IOSExistingFileMutation): IOSExistingFileMutation { + return { + path: mutation.path, + originalBytes: mutation.candidateBytes, + originalHash: mutation.candidateHash, + candidateBytes: mutation.originalBytes, + candidateHash: mutation.originalHash, + mode: mutation.mode, + }; +} + +function preparedSDKBlockers(prepared: PreparedIOSSDKInstallMutation): string { + return prepared.status === "blocked" ? blockerList(prepared.plan.blockers) : ""; +} + +async function prepareSDKForCommit( + plan: IOSSDKInstallPlan | undefined, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSSDKInstallMutation(plan); + if (prepared.status === "stale") { + throw new CliError( + "The Xcode project changed after the preview. No local setup changes were written; rerun clerk init.", + ); + } + if (prepared.status === "blocked") { + throw new CliError( + `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 new CliError( + "The Swift authentication view changed after the preview. No local setup changes were written; rerun clerk init.", + ); + } + if (prepared.status === "blocked") { + throw new CliError( + `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 new CliError( + "An entitlements file changed after the preview. No local setup changes were written; rerun clerk init.", + ); + } + if (prepared.status === "blocked") { + const reasons = blockerList(prepared.plan.blockers); + throw new CliError( + `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 new CliError( + "An iOS entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", + ); + } + if (prepared.status === "blocked") { + throw new CliError( + `The Sign in with Apple entitlement could no longer be prepared safely. No local setup changes were written:\n${blockerList(prepared.plan.blockers)}`, + ); + } + return prepared; +} + +function composeAppleMutations( + baseMutations: readonly IOSFileMutation[], + prepared: PreparedIOSAppleEntitlementMutation | undefined, +): IOSFileMutation[] { + if (prepared?.status !== "ready") return [...baseMutations]; + const consumed = new Set(prepared.consumedBaseMutationPaths); + return [ + ...baseMutations.filter((mutation) => !consumed.has(resolve(mutation.path))), + ...prepared.mutations, + ]; +} + +function existingMutationsOnly(mutations: readonly IOSFileMutation[]): IOSExistingFileMutation[] { + if (mutations.some((mutation) => "kind" in mutation && mutation.kind === "create")) { + throw new CliError( + "The approved iOS setup attempted to combine incompatible runtime and file-creation transactions. No additional local setup changes were written; rerun clerk init.", + ); + } + return mutations as IOSExistingFileMutation[]; +} + +function assertUniqueMutationPaths(mutations: readonly IOSFileMutation[]): void { + const paths = mutations.map((mutation) => resolve(mutation.path)); + if (new Set(paths).size !== paths.length) { + throw new CliError( + "The approved iOS setup produced overlapping file mutations. No local setup changes were written; rerun clerk init.", + ); + } +} + +async function validateSatisfiedAssociatedDomain(plan: IOSAssociatedDomainPlan): Promise { + const current = await planIOSAssociatedDomain({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return ( + current.status === "satisfied" && + (plan.expectedDomain == null || current.expectedDomain === plan.expectedDomain) + ); +} + +async function validateSatisfiedAppleEntitlement(plan: IOSAppleEntitlementPlan): Promise { + const current = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return current.status === "satisfied"; +} + +async function validateSatisfiedPrebuiltAuth(plan: IOSPrebuiltAuthPlan): Promise { + const current = await planIOSPrebuiltAuth({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return current.status === "satisfied" && current.sourcePath === plan.sourcePath; +} + +async function rollbackPreparedLocalMutations( + mutations: readonly IOSExistingFileMutation[], +): Promise { + if (mutations.length === 0) return; + const result = await applyIOSExistingFileTransaction( + [...mutations].reverse().map(reverseFileMutation), + [], + ); + if (result.status !== "applied") { + throw new CliError( + "The publishable-key update failed, and a concurrent local edit prevented the approved iOS setup from being restored completely. Inspect the previewed project and entitlements files before retrying.", + ); + } +} + +function requireDevelopmentKey( + setup: IOSLocalSetupResult, + publishableKey: string | undefined, +): string { + const planNeedsKey = Boolean( + setup.directConfigPlan || + setup.runtimeKeyPlan || + setup.runtimeKeyVerificationPlan || + setup.associatedDomainPlan?.requiresPublishableKey, + ); + if (planNeedsKey !== setup.requiresDevelopmentKey) { + throw new CliError( + "The approved iOS setup plan is internally inconsistent. No local setup changes were written; rerun clerk init.", + ); + } + if (!planNeedsKey) return ""; + if (!publishableKey) { + throw new CliError( + "The linked Clerk application's development publishable key was not available. No local setup changes were written.", + ); + } + return publishableKey; +} + +function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { + if (setup.prebuiltAuthRequested && !setup.prebuiltAuthPlan) { + throw new CliError( + "The approved iOS setup selected prebuilt authentication without a validated source plan. No local setup changes were written; rerun clerk init.", + ); + } + const expectedPrebuiltAuthActive = + setup.prebuiltAuthRequested || setup.prebuiltAuthPlan?.status === "satisfied"; + if (setup.prebuiltAuthActive !== expectedPrebuiltAuthActive) { + throw new CliError( + "The approved iOS setup contains inconsistent prebuilt authentication state. No local setup changes were written; rerun clerk init.", + ); + } + if (setup.prebuiltAuthAppleEntitlementPlan && !setup.prebuiltAuthActive) { + throw new CliError( + "The approved iOS setup contains an unselected AuthView capability plan. No local setup changes were written; rerun clerk init.", + ); + } + if ( + setup.directConfigPlan?.sourcePath && + setup.prebuiltAuthPlan?.status === "ready" && + setup.directConfigPlan.sourcePath === setup.prebuiltAuthPlan.sourcePath + ) { + throw new CliError( + "The approved iOS setup contains overlapping Swift source mutations. No local setup changes were written; rerun clerk init.", + ); + } + if ( + setup.runtimeKeyPlan && + (setup.associatedDomainPlan?.missingEntitlementsSettings || + setup.appleEntitlementPlan?.missingEntitlementsSettings) + ) { + throw new CliError( + "The approved iOS setup cannot combine a LocalSecrets write with new entitlements-file creation. No local setup changes were written; rerun clerk init.", + ); + } + const runtimePlans = [ + setup.directConfigPlan, + setup.runtimeKeyPlan, + setup.runtimeKeyVerificationPlan, + ].filter((plan) => plan != null); + if (runtimePlans.length > 1) { + throw new CliError( + "The approved iOS setup contains conflicting runtime configuration routes. No local setup changes were written; rerun clerk init.", + ); + } + 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 new CliError( + "The approved iOS setup no longer identifies one selected native target. No local setup changes were written; rerun clerk init.", + ); + } + 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 new CliError( + "The approved iOS setup no longer identifies one consistent Xcode target. No local setup changes were written; rerun clerk init.", + ); + } +} + +/** + * Commits a previously previewed iOS setup after authentication. Fresh direct + * configuration combines project.pbxproj and the Swift entry source in one + * guarded local transaction. Existing LocalSecrets integrations retain their + * specialized compatibility transaction. + */ +export async function applyIOSPlannedLocalSetup( + setup: IOSLocalSetupResult, + publishableKey?: string, + options: ApplyIOSPlannedLocalSetupOptions = {}, +): Promise { + assertCoherentLocalSetup(setup); + if (setup.prebuiltAuthActive) { + if (setup.nativeReadiness.target.status !== "selected") { + throw new CliError( + "The approved prebuilt AuthView setup no longer identifies one selected iOS target. No local setup changes were written; rerun clerk init.", + ); + } + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: setup.nativeReadiness.target.targetId, + }); + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( + inspection, + setup.directConfigPlan, + setup.runtimeKeyPlan, + ); + if (runtimeBlockers.length > 0) { + throw new CliError( + `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")}`, + ); + } + } + const key = requireDevelopmentKey(setup, publishableKey); + + // Existing LocalSecrets values are verified before any PBX mutation. A + // mismatched application can therefore never change the selected target. + if (setup.runtimeKeyVerificationPlan) { + const result = await withSpinner("Verifying the existing iOS publishable key...", async () => + verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key), + ); + assertRuntimeKeyVerificationMatched(result); + } + + const preparedSDK = await prepareSDKForCommit(setup.sdkInstallPlan); + const preparedPrebuiltAuth = await preparePrebuiltAuthForCommit(setup.prebuiltAuthPlan); + + if (setup.directConfigPlan) { + const preparedDirect = await prepareIOSDirectConfigMutation(setup.directConfigPlan, key); + if (preparedDirect.status === "stale") { + throw new CliError( + "The Swift app entry source changed after the preview. No local setup changes were written; rerun clerk init.", + ); + } + if (preparedDirect.status === "blocked") { + throw new CliError( + `The Swift app entry source could no longer be configured safely. No local setup changes were written:\n${blockerList(preparedDirect.plan.blockers)}`, + ); + } + // Verify an existing inline key before using the supplied key to derive + // its entitlements candidate. A mismatch must retain the dedicated + // wrong-application error and leave every file untouched. + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + + const baseMutations: IOSFileMutation[] = []; + const postconditions: Array<() => boolean | Promise> = []; + if (options.beforePostWriteValidation) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return true; + }); + } + if ( + preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ) { + baseMutations.push(preparedSDK.mutation); + } + if (preparedSDK) { + postconditions.push(async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)); + } + if (preparedAssociatedDomain?.status === "ready") { + baseMutations.push(...preparedAssociatedDomain.mutations); + postconditions.push(async () => + validatePreparedIOSAssociatedDomain(preparedAssociatedDomain), + ); + } else if (preparedAssociatedDomain?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan), + ); + } + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const mutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + if (preparedAppleEntitlement?.status === "ready") { + postconditions.push(async () => + validatePreparedIOSAppleEntitlement(preparedAppleEntitlement), + ); + } else if (preparedAppleEntitlement?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan), + ); + } + // Commit the entitlements file and its Xcode settings before Swift starts + // depending on the configured SDK. A process interruption can then leave + // only harmless project prerequisites, never source that imports an + // unlinked package. + if (preparedDirect.status === "ready") { + mutations.push(directFileMutation(preparedDirect)); + postconditions.push(async () => validatePreparedIOSDirectConfig(preparedDirect)); + } else { + postconditions.push(async () => { + const verified = await prepareIOSDirectConfigMutation(setup.directConfigPlan!, key); + return verified.status === "satisfied"; + }); + } + if (preparedPrebuiltAuth?.status === "ready") { + mutations.push(prebuiltAuthFileMutation(preparedPrebuiltAuth)); + postconditions.push(async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)); + } else if (preparedPrebuiltAuth?.status === "satisfied") { + postconditions.push(async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)); + } + if (setup.prebuiltAuthActive) { + postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup, false)); + } + assertUniqueMutationPaths(mutations); + + if (mutations.length > 0) { + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(mutations, postconditions), + ); + if (result.status === "stale") { + throw new CliError( + "An iOS setup file changed while the approved changes were being committed. Any partial write was restored; rerun clerk init.", + ); + } + if (result.status === "rolled-back") { + throw new CliError( + "The local iOS setup failed post-write validation and was restored byte-for-byte.", + ); + } + } + + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedDirect.status === "ready") { + log.success(`Clerk configured in ${preparedDirect.plan.sourcePath}`); + } else { + log.info(dim("The existing inline publishable key matches the linked Clerk application.")); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + return; + } + + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + const baseMutations: IOSFileMutation[] = [ + ...(preparedAssociatedDomain?.status === "ready" ? preparedAssociatedDomain.mutations : []), + ...(preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ? [preparedSDK.mutation] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [prebuiltAuthFileMutation(preparedPrebuiltAuth)] + : []), + ]; + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + assertUniqueMutationPaths(localMutations); + + // SDK-only and LocalSecrets compatibility routes apply the PBX candidate + // after authentication. If the specialized key transaction subsequently + // fails, restore the PBX bytes when they are still untouched. + if (localMutations.length > 0) { + const postconditions: Array<() => boolean | Promise> = [ + ...(preparedSDK ? [async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)] : []), + ...(preparedAssociatedDomain?.status === "ready" + ? [async () => validatePreparedIOSAssociatedDomain(preparedAssociatedDomain)] + : preparedAssociatedDomain?.status === "satisfied" + ? [async () => validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan)] + : []), + ...(preparedAppleEntitlement?.status === "ready" + ? [async () => validatePreparedIOSAppleEntitlement(preparedAppleEntitlement)] + : preparedAppleEntitlement?.status === "satisfied" + ? [async () => validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan)] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)] + : preparedPrebuiltAuth?.status === "satisfied" + ? [async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)] + : []), + ...(setup.prebuiltAuthActive + ? [ + async () => + validatePrebuiltAuthRuntimePostcondition(setup, setup.runtimeKeyPlan != null), + ] + : []), + ]; + if (setup.runtimeKeyVerificationPlan) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return ( + (await verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key)).status === "matched" + ); + }); + } + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(localMutations, postconditions), + ); + if (result.status === "stale") { + throw new CliError( + "The Xcode project changed after the preview. No SDK change was written; rerun clerk init.", + ); + } + if (result.status === "rolled-back") { + throw new CliError( + "The local iOS setup changed during post-write validation. The Clerk iOS SDK change was restored byte-for-byte; rerun clerk init.", + ); + } + if (!setup.runtimeKeyPlan && preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (!setup.runtimeKeyPlan && preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + if (!setup.runtimeKeyPlan && preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + } + + if (setup.runtimeKeyPlan) { + try { + await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan, key); + } catch (error) { + await rollbackPreparedLocalMutations(existingMutationsOnly(localMutations)); + throw error; + } + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + } else if (setup.runtimeKeyVerificationPlan) { + log.info(dim("The existing publishable key matches the linked Clerk application.")); + } +} + +export async function applyIOSRuntimeKeySetup( + plan: IOSRuntimeKeyPlan, + publishableKey: string, +): Promise { + const result = await withSpinner("Wiring the development publishable key...", async () => + applyIOSRuntimeKey(plan, publishableKey), + ); + if (result.status === "applied") { + log.success(`Publishable key wired to ${plan.localSecretsPath}`); + return; + } + if (result.status === "satisfied") { + log.info(dim(`The linked publishable key is already wired to ${plan.localSecretsPath}.`)); + return; + } + if (result.status === "stale") { + throw new CliError( + "LocalSecrets.plist or .gitignore changed after the preview. Nothing new was written; rerun clerk init to build a fresh plan.", + ); + } + if (result.status === "rolled-back") { + throw new CliError( + result.message ?? "The runtime-key update failed validation and was restored.", + ); + } + const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); + throw new CliError( + result.message ?? `The development publishable key could not be wired safely:\n${reasons}`, + ); +} + +export async function verifyIOSRuntimeKeySetup( + plan: IOSRuntimeKeyVerificationPlan, + linkedPublishableKey: string, +): Promise { + const result = await withSpinner("Verifying the existing iOS publishable key...", async () => + verifyIOSRuntimeKey(plan, linkedPublishableKey), + ); + assertRuntimeKeyVerificationMatched(result); + log.info(dim("The existing publishable key matches the linked Clerk application.")); +} + +function assertRuntimeKeyVerificationMatched( + result: Awaited>, +): void { + if (result.status === "matched") return; + if (result.status === "mismatched") { + throw new CliError( + "The existing iOS runtime publishable key does not match the linked Clerk application's development key. No key was changed; link the matching application or clear the existing runtime key intentionally before rerunning clerk init.", + ); + } + if (result.status === "stale") { + throw new CliError( + "LocalSecrets.plist changed after the read-only verification preflight. No key was changed; rerun clerk init.", + ); + } + const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); + throw new CliError( + `The existing iOS runtime publishable key could not be verified safely. No key was changed:\n${reasons}`, + ); +} diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 03c954b0..2b389535 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[] = []; @@ -452,7 +453,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"], }); @@ -468,6 +469,55 @@ describe("inspectTargetBuildConfigurations", () => { message: expect.stringContaining("missing-target-release"), }), ); + + const inspection: IOSProjectInspectionResult = { + schemaVersion: 1, + platform: "ios", + root, + workspaces: [], + projects: [], + appTargets: [ + { + id: "target", + name: "Example", + projectPath: "Example.xcodeproj", + configurations: configurations.map(({ model }) => model), + packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, + runtimeKeySinks: [], + swift: { + sourceFilesScanned: 0, + evidenceComplete: true, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + localSecretsRuntimeBindings: [], + environmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent", + }, + }, + ], + selection: { + state: "selected", + targetId: "target", + targetName: "Example", + projectPath: "Example.xcodeproj", + }, + localPublishableKey: { + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }, + generatedProject: null, + diagnostics, + }; + expect( + buildIOSSetupPlan(inspection).steps.find(({ id }) => id === "register-native-application"), + ).toMatchObject({ status: "blocked" }); }); test("taints target settings when the project configuration list is incomplete", async () => { diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts new file mode 100644 index 00000000..acf74e90 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -0,0 +1,557 @@ +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, + ...overrides, + }; +} + +async function createIsolatedCLIState(): Promise { + const configDir = await mkdtemp(join(tmpdir(), "clerk-ios-cli-config-")); + temporaryDirectories.push(configDir); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + profiles: {}, + telemetryNoticeShown: true, + machineUuid: "00000000-0000-4000-8000-000000000000", + }) + "\n", + ); + return configDir; +} + +async function runCLI(root: string, args: string[], env: Record) { + const child = Bun.spawn([process.execPath, cliPath, ...args], { + cwd: root, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} + +describe("clerk init --dry-run", () => { + test("non-TTY mode emits JSON without network requests or local/global writes", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["init", "--dry-run"], + isolatedCLIEnvironment(configDir, { + // Dev builds normally suppress telemetry. Pointing it at the trap + // makes a leaked global telemetry hook observable. + CLERK_TELEMETRY_URL: requestTrap.url.href, + }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toMatchObject({ + schemaVersion: 1, + mode: "read-only", + status: "ready", + inspection: { platform: "ios", selection: { state: "selected", targetName: "MyApp" } }, + plan: { kind: "clerk-ios-setup", status: "ready" }, + nativeReadiness: { + kind: "clerk-ios-native-readiness", + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + }, + }, + }); + expect(result.stdout).not.toContain("CLERK_PUBLISHABLE_KEY="); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit JSON output stays free of human-mode UI", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + mode: "read-only", + inspection: { selection: { state: "selected", targetName: "MyApp" } }, + }); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("fresh SwiftUI output advertises direct configuration and environment automation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-direct-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const associatedDomain = output.plan.steps.find( + (step: { id: string }) => step.id === "add-associated-domain", + ); + expect(configure).toMatchObject({ status: "required", automatable: true }); + expect(environment).toMatchObject({ status: "required", automatable: true }); + expect(associatedDomain).toMatchObject({ status: "required", automatable: true }); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + }); + expect(configure.description).toContain("directly"); + expect(result.stdout).not.toContain("LocalSecrets"); + expect(result.stdout).not.toContain("pk_test_"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit AuthView dry-run includes safe direct setup for an import-only core target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-auth-direct-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(configure).toMatchObject({ status: "required", automatable: true }); + expect(environment).toMatchObject({ status: "required", automatable: true }); + expect(auth).toMatchObject({ status: "required", automatable: true }); + expect(configure.description).toContain("directly"); + expect(environment.description).toContain(".environment(Clerk.shared)"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit AuthView dry-run blocks a ProcessInfo runtime without root environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-auth-process-info-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure( + publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "" + ) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const publishableKey = `pk_test_${Buffer.from("dry-run-process-info.clerk.example$").toString("base64")}`; + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(configure).toMatchObject({ status: "satisfied" }); + expect(environment).toMatchObject({ status: "required", automatable: false }); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain( + "Clerk.shared is not proven in the shipping SwiftUI root environment", + ); + expect(result.stdout).not.toContain(publishableKey); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit Apple opt-in previews only the local entitlement during a network-free dry-run", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-apple-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--sign-in-with-apple"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const apple = output.plan.steps.find( + (step: { id: string }) => step.id === "enable-native-apple", + ); + expect(apple).toMatchObject({ status: "required", automatable: true }); + expect(apple.description).toContain("native Sign in with Apple entitlement"); + expect(result.stdout).not.toContain("Services ID"); + expect(result.stdout).not.toContain("private key"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit prebuilt AuthView dry-run refuses to overwrite a partial existing flow without network access", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-prebuilt-auth-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain("not safe to rewrite automatically"); + expect(auth.description).toContain("network-free local plan"); + expect(JSON.stringify(output)).not.toContain("connection_oauth_apple"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit prebuilt AuthView dry-run blocks a target below iOS 17 without network access", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-prebuilt-auth-ios16-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await Bun.write( + projectPath, + (await Bun.file(projectPath).text()).replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0", + "IPHONEOS_DEPLOYMENT_TARGET = 16.4", + ), + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain("require iOS 17.0 or newer"); + expect(auth.description).toContain("IPHONEOS_DEPLOYMENT_TARGET"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("advertises missing-entitlements creation for a satisfied LocalSecrets integration", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-local-secrets-domain-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "configure-publishable-key", + status: "satisfied", + }), + ); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "add-associated-domain", + status: "required", + automatable: true, + }), + ); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + blockers: [], + }); + expect(result.stdout).not.toContain("pk_live_"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not advertise runtime-key automation when the strict plist preflight blocks", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + expect(configure).toMatchObject({ status: "blocked", automatable: false }); + expect(configure.description).toContain("readable XML property-list dictionary"); + expect(configure.description).not.toContain("clerk init can fetch"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("rejects remote-state flags before authentication or linking", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["init", "--dry-run", "--app", "app_never_contact"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--dry-run cannot be combined"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("human output labels an ambiguous target as incomplete without implying failure", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { secondTarget: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("Setup incomplete"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("Plan blocked"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("human output distinguishes an actionable plan from a ready plan", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Setup incomplete"); + expect(output).not.toContain("Setup looks ready"); + expect(output).toContain("not inspected during this local-only dry-run"); + expect(output).toContain("Regular"); + expect(output).toContain("audits and safely reconciles both"); + expect(output).not.toContain("does not expose these resources"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts new file mode 100644 index 00000000..932ed9f6 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -0,0 +1,676 @@ +import { describe, expect, test } from "bun:test"; +import { UserAbortError } from "../../../lib/errors.ts"; +import type { InstanceConfigSchema } from "../../../lib/plapi.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { + applyIOSNativeAppleConnection, + buildIOSNativeApplePlan, + prepareIOSNativeAppleConnection, + type IOSNativeAppleAPI, + type IOSNativeApplePatchOptions, + type IOSNativeApplePrompts, +} from "./native-apple.ts"; + +const APPLICATION_ID = "app_native_apple"; +const INSTANCE_ID = "ins_native_apple"; +const BUNDLE_IDENTIFIER = "com.example.NativeApple"; +const CONFIG_VERSION = "v1_1234abcd"; +const NEXT_CONFIG_VERSION = "v1_9876fedc"; +const SERVICES_ID = "com.example.web.sign-in"; +const TEAM_ID = "APPLE_TEAM_ID_MUST_NOT_ESCAPE"; +const KEY_ID = "APPLE_KEY_ID_MUST_NOT_ESCAPE"; +const PRIVATE_KEY = "APPLE_PRIVATE_KEY_MUST_NOT_ESCAPE"; +const API_SECRET = "Bearer ak_PLATFORM_TOKEN_MUST_NOT_ESCAPE"; + +const captured = useCaptureLog(); + +type AppleConnection = Record & { + enabled: boolean; + authenticatable: boolean; +}; + +function appleSchema(): InstanceConfigSchema { + return { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, + }, + }, + }; +} + +function connection( + enabled = false, + authenticatable = true, + extras: Record = {}, +): AppleConnection { + return { enabled, authenticatable, ...extras }; +} + +function config(value: AppleConnection, configVersion: string | undefined = CONFIG_VERSION) { + return { + ...(configVersion ? { config_version: configVersion } : {}), + connection_oauth_apple: { ...value }, + }; +} + +function baseOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + requested: true, + agent: false, + yes: true, + ...overrides, + }; +} + +function unexpectedPrompts(overrides: Partial = {}): IOSNativeApplePrompts { + return { + enableNativeApple: + overrides.enableNativeApple ?? + (async () => { + throw new Error("unexpected Apple opt-in prompt"); + }), + confirmChanges: + overrides.confirmChanges ?? + (async () => { + throw new Error("unexpected Apple mutation prompt"); + }), + }; +} + +type PatchCall = { + config: Record; + options: IOSNativeApplePatchOptions; +}; + +function statefulAPI( + options: { + initial?: AppleConnection; + schema?: InstanceConfigSchema; + supportsIfMatch?: boolean; + version?: string | undefined; + failFetch?: unknown; + failDryRun?: unknown; + failActual?: unknown; + malformedDryRun?: boolean; + replaceProjection?: boolean; + persistActual?: boolean; + } = {}, +): { + api: IOSNativeAppleAPI; + calls: string[]; + patchCalls: PatchCall[]; + actualWrites(): number; + current(): AppleConnection; + setCurrent(value: AppleConnection): void; + setVersion(value: string | undefined): void; +} { + let current = { + ...(options.initial ?? connection()), + } as AppleConnection; + let version: string | undefined = + options.version === undefined ? CONFIG_VERSION : options.version; + let writes = 0; + const calls: string[] = []; + const patchCalls: PatchCall[] = []; + + const api: IOSNativeAppleAPI = { + supportsIfMatch: options.supportsIfMatch ?? false, + async fetchInstanceConfig(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET config"); + if (options.failFetch) throw options.failFetch; + return config(current, version); + }, + async fetchInstanceConfigSchema(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET schema"); + if (options.failFetch) throw options.failFetch; + return options.schema ?? appleSchema(); + }, + async patchInstanceConfig(applicationId, instanceId, patch, patchOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push(patchOptions.dryRun ? "PATCH dry-run" : "PATCH apply"); + patchCalls.push({ + config: structuredClone(patch), + options: { ...patchOptions }, + }); + + if (patchOptions.ifMatch && patchOptions.ifMatch !== version) { + throw new Error("config version conflict"); + } + if (patchOptions.dryRun && options.failDryRun) throw options.failDryRun; + if (!patchOptions.dryRun && options.failActual) throw options.failActual; + + const update = patch.connection_oauth_apple; + if (typeof update !== "object" || update == null || Array.isArray(update)) { + throw new Error("invalid test patch"); + } + const before = { ...current }; + const after = ( + options.replaceProjection + ? { ...(update as Record) } + : { ...current, ...(update as Record) } + ) as AppleConnection; + if (patchOptions.dryRun && options.malformedDryRun) { + return { config_version: version, dry_run: true, before: {}, after: {} }; + } + if (!patchOptions.dryRun) { + writes += 1; + if (options.persistActual !== false) current = after; + version = NEXT_CONFIG_VERSION; + } + return { + config_version: patchOptions.dryRun ? version : NEXT_CONFIG_VERSION, + dry_run: patchOptions.dryRun, + before: { connection_oauth_apple: before }, + after: { connection_oauth_apple: after }, + }; + }, + }; + + return { + api, + calls, + patchCalls, + actualWrites: () => writes, + current: () => ({ ...current }), + setCurrent(value) { + current = { ...value }; + }, + setVersion(value) { + version = value; + }, + }; +} + +describe("native Sign in with Apple remote setup", () => { + test("builds a narrow redacted plan without retaining web credentials", () => { + const sensitiveConnection = connection(false, true, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }); + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(sensitiveConnection), + schema: appleSchema(), + }); + + expect(plan).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: false, authenticatable: true }, + desired: { enabled: true, authenticatable: true }, + configVersion: CONFIG_VERSION, + blockers: [], + }); + expect(plan.actions).toHaveLength(1); + const serialized = JSON.stringify(plan); + for (const sensitive of [SERVICES_ID, PRIVATE_KEY, TEAM_ID, KEY_ID]) { + expect(serialized).not.toContain(sensitive); + } + }); + + test("treats an existing enabled and authenticatable connection as a no-op", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan.status).toBe("satisfied"); + expect(harness.patchCalls).toHaveLength(0); + if (plan.status === "satisfied") { + await applyIOSNativeAppleConnection(plan, harness.api); + } + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + expect(captured.err).toContain("already enabled"); + }); + + test("rejects a satisfied plan when the connection changes after prepare", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + + harness.setCurrent( + connection(false, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_secret: PRIVATE_KEY, + }), + ); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(plan, harness.api); + } catch (error) { + thrown = error; + } + expect(String(thrown)).toContain("changed after the approved preview"); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(PRIVATE_KEY); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + }); + + test("prepares before a planned native registration, then preserves web credentials on apply", async () => { + const initial = connection(false, false, { + client_id: SERVICES_ID, + client_secret: "REDACTED", + team_id: TEAM_ID, + key_id: KEY_ID, + unrelated_provider_setting: "keep-me", + }); + const harness = statefulAPI({ + initial, + supportsIfMatch: true, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + // The exact iOS registration may still be an approved prerequisite here. + // Server validation is intentionally deferred until apply, after the + // registration transaction has run. + expect(harness.patchCalls).toHaveLength(0); + + await applyIOSNativeAppleConnection(prepared, harness.api); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls).toHaveLength(2); + for (const call of harness.patchCalls) { + expect(call.config).toEqual({ + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }); + expect(call.options.ifMatch).toBe(CONFIG_VERSION); + expect(JSON.stringify(call.config)).not.toContain(SERVICES_ID); + expect(JSON.stringify(call.config)).not.toContain(TEAM_ID); + expect(JSON.stringify(call.config)).not.toContain(KEY_ID); + } + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.current()).toEqual({ + ...initial, + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }); + }); + + test("uses config-version rereads when an injected transport cannot send If-Match", async () => { + const harness = statefulAPI({ supportsIfMatch: false }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared.status).toBe("ready"); + expect(harness.patchCalls).toHaveLength(0); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await applyIOSNativeAppleConnection(prepared, harness.api); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls).toHaveLength(2); + for (const call of harness.patchCalls) expect(call.options.ifMatch).toBeUndefined(); + + const staleHarness = statefulAPI({ supportsIfMatch: false }); + const stalePrepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: staleHarness.api, + prompts: unexpectedPrompts(), + }); + if (stalePrepared.status !== "ready") throw new Error("expected ready plan"); + staleHarness.setVersion(NEXT_CONFIG_VERSION); + + await expect(applyIOSNativeAppleConnection(stalePrepared, staleHarness.api)).rejects.toThrow( + "changed after the approved preview", + ); + expect(staleHarness.patchCalls).toHaveLength(0); + expect(staleHarness.actualWrites()).toBe(0); + }); + + test("requires the exact native Bundle ID even when Apple is already authenticatable", async () => { + const harness = statefulAPI({ initial: connection(true, true) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + }); + expect(harness.patchCalls).toHaveLength(0); + }); + + test("keeps global --yes from opting an agent into Apple", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, agent: true, yes: true }), + { api: harness.api, prompts: unexpectedPrompts() }, + ); + + expect(prepared).toEqual({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason: "not-requested", + }); + expect(harness.calls).toEqual([]); + }); + + test("lets a human decline the opt-in before any remote read", async () => { + const harness = statefulAPI(); + let optInCalls = 0; + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, yes: true }), + { + api: harness.api, + prompts: unexpectedPrompts({ + enableNativeApple: async (bundleIdentifier) => { + optInCalls += 1; + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + return false; + }, + }), + }, + ); + + expect(prepared.status).toBe("skipped"); + expect(optInCalls).toBe(1); + expect(harness.calls).toEqual([]); + }); + + test("requires separate human mutation consent without calling the mutation endpoint", async () => { + const harness = statefulAPI(); + let consentCalls = 0; + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ yes: false }), { + api: harness.api, + prompts: unexpectedPrompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires --yes for an explicitly requested agent mutation", async () => { + const harness = statefulAPI(); + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ agent: true, yes: false }), { + api: harness.api, + prompts: unexpectedPrompts(), + }), + ).rejects.toThrow("requires explicit mutation consent"); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test.each([ + { + name: "the exact native application is not ready", + nativeApplicationReady: false, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: appleSchema(), + blocker: "native-application-not-ready", + }, + { + name: "the Bundle ID is missing", + nativeApplicationReady: true, + bundleIdentifier: " ", + value: connection(), + schema: appleSchema(), + blocker: "bundle-identifier-unavailable", + }, + { + name: "the schema does not prove the exact native Bundle ID patch", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + }, + }, + }, + } as InstanceConfigSchema, + blocker: "apple-config-unsupported", + }, + { + name: "the current config is malformed", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: { enabled: "yes", authenticatable: true } as unknown as AppleConnection, + schema: appleSchema(), + blocker: "apple-config-invalid", + }, + { + name: "Apple is enabled but deliberately not authenticatable", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(true, false), + schema: appleSchema(), + blocker: "apple-authenticatable-conflict", + }, + { + name: "an existing Apple Bundle ID conflicts", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(false, true, { bundle_id: "com.example.OtherApp" }), + schema: appleSchema(), + blocker: "apple-bundle-identifier-conflict", + }, + ])("fails closed when $name", (fixture) => { + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: fixture.bundleIdentifier, + nativeApplicationReady: fixture.nativeApplicationReady, + config: config(fixture.value), + schema: fixture.schema, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: fixture.blocker })); + }); + + test("fails before writing when the approved config version becomes stale", async () => { + const harness = statefulAPI({ supportsIfMatch: true }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + harness.setVersion(NEXT_CONFIG_VERSION); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "changed after the approved preview", + ); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires a valid server dry-run projection before the actual write", async () => { + const harness = statefulAPI({ malformedDryRun: true }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.actualWrites()).toBe(0); + }); + + test("rejects a dry-run projection that drops existing Apple credential fields", async () => { + const harness = statefulAPI({ + initial: connection(false, false, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }), + replaceProjection: true, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rereads final state and rejects a write that did not persist", async () => { + const harness = statefulAPI({ persistActual: false }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "did not pass final verification", + ); + expect(harness.actualWrites()).toBe(1); + expect(harness.current().enabled).toBe(false); + }); + + test("sanitizes read, dry-run, and write API failures", async () => { + const readHarness = statefulAPI({ failFetch: new Error(API_SECRET) }); + let readError: unknown; + try { + await prepareIOSNativeAppleConnection(baseOptions(), { + api: readHarness.api, + prompts: unexpectedPrompts(), + }); + } catch (error) { + readError = error; + } + expect(String(readError)).not.toContain(API_SECRET); + + const dryRunHarness = statefulAPI({ failDryRun: new Error(PRIVATE_KEY) }); + const dryRunPrepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: dryRunHarness.api, + prompts: unexpectedPrompts(), + }); + if (dryRunPrepared.status !== "ready") throw new Error("expected ready plan"); + let dryRunError: unknown; + try { + await applyIOSNativeAppleConnection(dryRunPrepared, dryRunHarness.api); + } catch (error) { + dryRunError = error; + } + expect(String(dryRunError)).not.toContain(PRIVATE_KEY); + + const writeHarness = statefulAPI({ failActual: new Error(TEAM_ID) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: writeHarness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + let writeError: unknown; + try { + await applyIOSNativeAppleConnection(prepared, writeHarness.api); + } catch (error) { + writeError = error; + } + expect(String(writeError)).not.toContain(TEAM_ID); + + const allOutput = `${captured.err}\n${JSON.stringify({ readError, dryRunError, writeError })}`; + for (const sensitive of [API_SECRET, PRIVATE_KEY, TEAM_ID, KEY_ID, SERVICES_ID]) { + expect(allOutput).not.toContain(sensitive); + } + }); + + test("accepts a missing config version but blocks malformed version material", () => { + const withoutVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: { connection_oauth_apple: connection() }, + schema: appleSchema(), + }); + expect(withoutVersion.status).toBe("ready"); + expect(withoutVersion.configVersion).toBeUndefined(); + + const sensitiveVersion = `v1_${PRIVATE_KEY}`; + const malformedVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(connection(), sensitiveVersion), + schema: appleSchema(), + }); + expect(malformedVersion.status).toBe("blocked"); + expect(JSON.stringify(malformedVersion)).not.toContain(PRIVATE_KEY); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts new file mode 100644 index 00000000..da77ce87 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -0,0 +1,619 @@ +import { dim, yellow } from "../../../lib/color.ts"; +import { CliError, 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}$/; + +type AppleConnectionState = { + enabled: boolean; + authenticatable: boolean; +}; + +export type IOSNativeAppleBlockerCode = + | "native-application-not-ready" + | "bundle-identifier-unavailable" + | "apple-config-unsupported" + | "apple-config-invalid" + | "apple-authenticatable-conflict" + | "apple-bundle-identifier-conflict"; + +export interface IOSNativeAppleBlocker { + code: IOSNativeAppleBlockerCode; + message: string; +} + +/** + * Serializable, credential-free preview of the remote Apple connection work. + * The raw Platform Config response must never be attached to this value. + */ +export type IOSNativeApplePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + configVersion?: string; + connection: "required" | "satisfied" | "blocked"; + bundleIdentifierConfiguration: "required" | "satisfied" | "blocked"; + current?: AppleConnectionState; + desired: AppleConnectionState; + actions: string[]; + blockers: IOSNativeAppleBlocker[]; +}; + +export type IOSNativeAppleSkipped = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "skipped"; + reason: "not-requested" | "declined"; +}; + +export type IOSNativeApplePreparation = IOSNativeApplePlan | IOSNativeAppleSkipped; + +export interface IOSNativeApplePatchOptions { + dryRun: boolean; + /** Forwarded only by clients which explicitly advertise support. */ + ifMatch?: string; +} + +export interface IOSNativeAppleAPI { + /** + * PLAPI supports both server dry-run and If-Match. Test or alternate + * adapters may opt out of If-Match; config-version revalidation remains + * mandatory either way. + */ + supportsIfMatch?: boolean; + fetchInstanceConfig( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise>; + fetchInstanceConfigSchema( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise; + patchInstanceConfig( + applicationId: string, + instanceId: string, + config: Record, + options: IOSNativeApplePatchOptions, + ): Promise>; +} + +const defaultAPI: IOSNativeAppleAPI = { + supportsIfMatch: true, + fetchInstanceConfig, + fetchInstanceConfigSchema, + patchInstanceConfig: async (applicationId, instanceId, config, options) => + patchInstanceConfig(applicationId, instanceId, config, { + dryRun: options.dryRun, + ifMatch: options.ifMatch, + }), +}; + +export interface IOSNativeApplePrompts { + enableNativeApple(bundleIdentifier: string): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeApplePrompts = { + enableNativeApple: async (bundleIdentifier) => + confirm({ + message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + default: false, + }), + confirmChanges: async () => + confirm({ + message: "Apply this remote Clerk Sign in with Apple change?", + default: false, + }), +}; + +export interface IOSNativeAppleOptions { + applicationId: string; + instanceId: string; + bundleIdentifier: string; + /** + * The exact selected target's registration is already satisfied or is an + * approved prerequisite which the caller will apply before this plan. + */ + nativeApplicationReady: boolean; +} + +export interface PrepareIOSNativeAppleOptions extends IOSNativeAppleOptions { + /** + * `undefined` prompts a human but defaults to skipped in agent mode. `--yes` + * is mutation consent only and never opts a project into Apple by itself. + */ + requested?: boolean; + agent: boolean; + yes: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function blocker(code: IOSNativeAppleBlockerCode, message: string): IOSNativeAppleBlocker { + return { code, message }; +} + +function schemaSupportsNarrowApplePatch(schema: InstanceConfigSchema): boolean { + const connection = schema.properties?.[APPLE_CONNECTION_KEY]; + return ( + connection?.type === "object" && + connection.properties?.enabled?.type === "boolean" && + connection.properties?.authenticatable?.type === "boolean" && + connection.properties?.bundle_id?.type === "string" + ); +} + +type ParsedConnection = + | { status: "valid"; value: AppleConnectionState; bundleIdentifier?: string } + | { status: "invalid" }; + +function parseConnection(container: unknown): ParsedConnection { + if (!isRecord(container)) return { status: "invalid" }; + const connection = container[APPLE_CONNECTION_KEY]; + if (!isRecord(connection)) return { status: "invalid" }; + if (typeof connection.enabled !== "boolean" || typeof connection.authenticatable !== "boolean") { + return { status: "invalid" }; + } + + const bundleIdentifier = connection.bundle_id; + if (bundleIdentifier !== undefined && typeof bundleIdentifier !== "string") { + return { status: "invalid" }; + } + return { + status: "valid", + value: { + enabled: connection.enabled, + authenticatable: connection.authenticatable, + }, + ...(typeof bundleIdentifier === "string" && bundleIdentifier.trim() + ? { bundleIdentifier: bundleIdentifier.trim() } + : {}), + }; +} + +function parseConfigVersion( + container: Record, +): { status: "missing" } | { status: "valid"; value: string } | { status: "invalid" } { + const value = container.config_version; + if (value == null) return { status: "missing" }; + if (typeof value !== "string" || !CONFIG_VERSION_PATTERN.test(value)) { + return { status: "invalid" }; + } + return { status: "valid", value }; +} + +export function buildIOSNativeApplePlan( + options: IOSNativeAppleOptions & { + config: Record; + schema: InstanceConfigSchema; + }, +): IOSNativeApplePlan { + const blockers: IOSNativeAppleBlocker[] = []; + const bundleIdentifier = options.bundleIdentifier.trim(); + if (!bundleIdentifier) { + blockers.push( + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID for the selected iOS target before enabling native Sign in with Apple.", + ), + ); + } + if (!options.nativeApplicationReady) { + blockers.push( + blocker( + "native-application-not-ready", + "Verify the exact selected iOS target's Clerk Native Application registration before enabling native Sign in with Apple.", + ), + ); + } + if (!schemaSupportsNarrowApplePatch(options.schema)) { + blockers.push( + blocker( + "apple-config-unsupported", + "This Clerk instance does not expose the narrow native Apple connection configuration required by clerk init.", + ), + ); + } + + const parsed = parseConnection(options.config); + if (parsed.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The existing Apple connection configuration could not be interpreted safely. Review it in the Clerk Dashboard before continuing.", + ), + ); + } + + const configVersion = parseConfigVersion(options.config); + if (configVersion.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The Apple connection configuration version could not be interpreted safely. Rerun clerk init before making remote changes.", + ), + ); + } + + if ( + parsed.status === "valid" && + parsed.bundleIdentifier && + bundleIdentifier && + parsed.bundleIdentifier !== bundleIdentifier + ) { + blockers.push( + blocker( + "apple-bundle-identifier-conflict", + "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + ), + ); + } + + if (parsed.status === "valid" && parsed.value.enabled && !parsed.value.authenticatable) { + blockers.push( + blocker( + "apple-authenticatable-conflict", + "Apple is enabled but intentionally unavailable for authentication. clerk init will not override that policy automatically.", + ), + ); + } + + const current = parsed.status === "valid" ? parsed.value : undefined; + const desired: AppleConnectionState = { enabled: true, authenticatable: true }; + const bundleIdentifierConfiguration = + blockers.length > 0 + ? "blocked" + : parsed.status !== "valid" + ? "blocked" + : parsed.bundleIdentifier === bundleIdentifier + ? "satisfied" + : "required"; + const connection = + blockers.length > 0 + ? "blocked" + : current?.enabled === true && + current.authenticatable === true && + bundleIdentifierConfiguration === "satisfied" + ? "satisfied" + : "required"; + const status = + connection === "blocked" ? "blocked" : connection === "satisfied" ? "satisfied" : "ready"; + const actions = + status === "ready" + ? [ + `Enable native Sign in with Apple for ${bundleIdentifier} by setting enabled, authenticatable, and the exact registered Bundle ID; preserve all existing web credential fields.`, + ] + : []; + + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), + connection, + bundleIdentifierConfiguration, + ...(current ? { current } : {}), + desired, + actions, + blockers, + }; +} + +export async function auditIOSNativeAppleConnection( + options: IOSNativeAppleOptions, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + let config: Record; + let schema: InstanceConfigSchema; + try { + [config, schema] = await withSpinner( + "Auditing Clerk Sign in with Apple settings...", + async () => + Promise.all([ + api.fetchInstanceConfig(options.applicationId, options.instanceId, [ + APPLE_CONNECTION_KEY, + ]), + api.fetchInstanceConfigSchema(options.applicationId, options.instanceId, [ + APPLE_CONNECTION_KEY, + ]), + ]), + ); + } catch { + throw new CliError( + "Clerk Sign in with Apple settings could not be inspected safely. No remote Apple connection changes were made; verify application access and rerun clerk init.", + ); + } + + return buildIOSNativeApplePlan({ ...options, config, schema }); +} + +function skipped(reason: IOSNativeAppleSkipped["reason"]): IOSNativeAppleSkipped { + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason, + }; +} + +function formatBlockers(plan: IOSNativeApplePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +function patchOptions( + api: IOSNativeAppleAPI, + plan: IOSNativeApplePlan, + dryRun: boolean, +): IOSNativeApplePatchOptions { + return { + dryRun, + ...(api.supportsIfMatch && plan.configVersion ? { ifMatch: plan.configVersion } : {}), + }; +} + +function applePatch(bundleIdentifier: string): Record { + // This intentionally excludes client_id, client_secret, team_id, key_id, + // and every other hosted/web credential field. The exact registered native + // Bundle ID is the only provider setting written. PLAPI's nested merge + // semantics preserve fields which are not explicitly provided. + return { + [APPLE_CONNECTION_KEY]: { + enabled: true, + authenticatable: true, + bundle_id: bundleIdentifier, + }, + }; +} + +function validatePatchProjection( + response: Record, + expectedBefore: AppleConnectionState, + expectedBundleConfiguration: IOSNativeApplePlan["bundleIdentifierConfiguration"], + bundleIdentifier: string, + dryRun: boolean, +): void { + if (response.dry_run !== dryRun || !isRecord(response.before) || !isRecord(response.after)) { + throw new Error("invalid Apple config patch response"); + } + const beforeConnection = response.before[APPLE_CONNECTION_KEY]; + const afterConnection = response.after[APPLE_CONNECTION_KEY]; + if ( + !isRecord(beforeConnection) || + !isRecord(afterConnection) || + Object.keys(beforeConnection).some((key) => !Object.hasOwn(afterConnection, key)) + ) { + throw new Error("Apple config patch projection removed existing fields"); + } + const before = parseConnection(response.before); + const after = parseConnection(response.after); + const beforeBundleConfiguration = + before.status !== "valid" + ? "blocked" + : before.bundleIdentifier === bundleIdentifier + ? "satisfied" + : before.bundleIdentifier == null + ? "required" + : "blocked"; + if ( + before.status !== "valid" || + after.status !== "valid" || + before.value.enabled !== expectedBefore.enabled || + before.value.authenticatable !== expectedBefore.authenticatable || + beforeBundleConfiguration !== expectedBundleConfiguration || + !after.value.enabled || + !after.value.authenticatable || + after.bundleIdentifier !== bundleIdentifier + ) { + throw new Error("unexpected Apple config patch projection"); + } + if (parseConfigVersion(response).status === "invalid") { + throw new Error("invalid Apple config patch version"); + } +} + +async function validateServerPatch( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, + dryRun: boolean, +): Promise { + if (!plan.current) throw new Error("missing approved Apple connection state"); + const response = await api.patchInstanceConfig( + plan.applicationId, + plan.instanceId, + applePatch(plan.bundleIdentifier), + patchOptions(api, plan, dryRun), + ); + validatePatchProjection( + response, + plan.current, + plan.bundleIdentifierConfiguration, + plan.bundleIdentifier, + dryRun, + ); +} + +async function preflightIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, +): Promise { + try { + await withSpinner("Validating the native Apple connection change...", async () => + validateServerPatch(plan, api, true), + ); + } catch { + throw new CliError( + "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.", + ); + } +} + +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 new CliError( + `Native Sign in with Apple could not be enabled safely. No remote Apple connection changes were made:\n${formatBlockers(plan)}`, + ); + } + if (plan.status === "satisfied") { + log.info(dim("Native Sign in with Apple is already enabled in Clerk.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk change:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n This native-only setup will not request, replace, or print an Apple Services ID, Team ID, Key ID, or private key.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing the Clerk Apple connection in agent mode requires explicit mutation consent. Rerun the same command with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + return ( + current.applicationId === approved.applicationId && + current.instanceId === approved.instanceId && + current.bundleIdentifier === approved.bundleIdentifier + ); +} + +function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + if (!approved.configVersion) return true; + return current.configVersion === approved.configVersion; +} + +export async function applyIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + if (plan.status === "blocked" || !plan.current || !plan.bundleIdentifier) { + throw new CliError( + "The approved native Apple connection plan is incomplete. No remote Apple connection changes were made; rerun clerk init.", + ); + } + 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 { + throw new CliError( + "Clerk Sign in with Apple settings could not be rechecked. No remote Apple connection changes were made; rerun clerk init.", + ); + } + + if (!planIdentityMatches(plan, current)) { + throw new CliError( + "The approved native Apple connection target changed. No remote Apple connection changes were made; rerun clerk init to review the new plan.", + ); + } + if (approvedWasSatisfied) { + if (current.status !== "satisfied") { + throw new CliError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ); + } + 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 new CliError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ); + } + + await preflightIOSNativeAppleConnection(current, api); + + try { + await withSpinner("Enabling native Sign in with Apple in Clerk...", async () => + validateServerPatch(current, api, false), + ); + } catch { + throw new CliError( + "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 { + throw new CliError( + "Native Sign in with Apple was submitted but its final Clerk state could not be verified. Rerun clerk init to inspect it safely.", + ); + } + if (finalPlan.status !== "satisfied") { + throw new CliError( + "Native Sign in with Apple did not pass final verification. Rerun clerk init to reconcile the remote state safely.", + ); + } + 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 00000000..36f6dcca --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { + buildIOSNativeReadinessAudit, + IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + suggestAppIdPrefixFromDevelopmentTeam, +} from "./native-readiness.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function inspectionFor( + options: Parameters[1] = {}, + target?: string, +) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-native-readiness-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + return inspectIOSProject(root, { target }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSNativeReadinessAudit", () => { + test("reports a redacted selected-target identity and the exact authenticated PLAPI bridge", async () => { + const inspection = await inspectionFor({ complete: true }); + const selected = inspection.appTargets[0]!; + for (const configuration of selected.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "DEVELOPMENT_TEAM_MUST_NOT_ESCAPE", + evidence: [], + }; + if (configuration.entitlements) { + configuration.entitlements.teamIdentifier = "ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"; + } + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: { + status: "selected", + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: "LEGACY1234", + }, + }, + associatedDomain: { + status: "review", + expectedDomain: "webcredentials:clerk.example.test", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + }, + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }); + expect(audit.remote.requirement).toEqual({ + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], + }); + expect(JSON.stringify(audit)).not.toContain("DEVELOPMENT_TEAM_MUST_NOT_ESCAPE"); + expect(JSON.stringify(audit)).not.toContain("ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"); + }); + + test("offers one unanimous Xcode Development Team only as an unverified suggestion", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + expect(JSON.stringify(buildIOSNativeReadinessAudit(inspection))).not.toContain("ABCDE12345"); + }); + + test("withholds the Xcode Development Team suggestion unless every configuration agrees", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.developmentTeam = { + state: "resolved", + value: "ZZZZZ99999", + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { state: "missing", evidence: [] }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { + state: "unresolved", + raw: "$(APPLE_TEAM)", + missingVariables: ["APPLE_TEAM"], + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + for (const configuration of target.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "NOT-A-TEAM", + evidence: [], + }; + } + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + }); + + test("requires the bare domain when only Apple's developer-mode entry is present", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "required", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: true, + blockers: [], + }); + }); + + test("recognizes the exact bare domain as locally satisfied", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["webcredentials:native.clerk.example"]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "satisfied", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [], + }); + }); + + test("blocks automation when configurations have mixed entitlements evidence", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = undefined; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.status).toBe("required"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.files).toEqual(["MyApp/MyApp.entitlements"]); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "missing-or-unreadable-entitlements" }), + ); + }); + + test("carries a strict Associated Domains blocker into native readiness", async () => { + const inspection = await inspectionFor({ complete: true }); + const associatedDomainPlan: IOSAssociatedDomainPlan = { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: "blocked", + root: inspection.root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + requiresPublishableKey: false, + files: [], + actions: [], + blockers: [{ code: "generated-project", message: "Update the project source definition." }], + }; + + const audit = buildIOSNativeReadinessAudit(inspection, { associatedDomainPlan }); + + expect(audit.associatedDomain).toMatchObject({ status: "review", automatable: false }); + expect(audit.associatedDomain.blockers).toContainEqual({ + code: "manual-review-required", + message: "Update the project source definition.", + }); + }); + + test("preserves all distinct existing XML entitlements routes", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const target = inspection.appTargets[0]!; + const release = target.configurations[1]!; + release.entitlements = { + ...release.entitlements!, + path: "MyApp/MyApp-Release.entitlements", + associatedDomains: [], + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp-Release.entitlements", "MyApp/MyApp.entitlements"], + blockers: [], + }); + }); + + test("does not claim a single bundle identifier or App ID Prefix when they conflict", async () => { + const inspection = await inspectionFor({ complete: true, conflictingBundle: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = { + ...target.configurations[1]!.entitlements!, + literalAppIdentifierPrefix: "OTHERPREFIX", + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + bundleIdentifier: { + status: "conflicting", + candidates: ["com.example.MyApp", "com.example.MyApp.release"], + }, + appIdPrefix: { + status: "conflicting", + source: "literal-entitlements", + candidates: ["LEGACY1234", "OTHERPREFIX"], + }, + }); + }); + + test("preserves a partial App ID Prefix candidate when one selected configuration lacks it", async () => { + const inspection = await inspectionFor({ complete: true }); + const releaseEntitlements = inspection.appTargets[0]!.configurations[1]!.entitlements!; + delete releaseEntitlements.literalAppIdentifierPrefix; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + appIdPrefix: { + status: "missing", + source: "literal-entitlements", + candidates: ["LEGACY1234"], + }, + }); + }); + + test("blocks identity and entitlement routing when target selection is ambiguous", async () => { + const inspection = await inspectionFor({ secondTarget: true }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toEqual({ status: "blocked", reason: "target-not-selected" }); + expect(audit.associatedDomain).toMatchObject({ + status: "blocked", + files: [], + automatable: false, + }); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "target-not-selected" }), + ); + }); + + test("does not invent a domain without redacted publishable-key metadata", async () => { + const inspection = await inspectionFor({ complete: false, includeKey: false }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.expectedDomain).toBeUndefined(); + expect(audit.associatedDomain.status).toBe("blocked"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "expected-domain-unavailable" }), + ); + }); + + test("never copies an unexpected raw publishable-key property", async () => { + const inspection = await inspectionFor({ complete: true }); + const key = `pk_test_${Buffer.from("must-not-escape.example$").toString("base64")}`; + (inspection.localPublishableKey as unknown as Record).publishableKey = key; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(JSON.stringify(audit)).not.toContain(key); + expect(JSON.stringify(audit)).not.toContain("publishableKey"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts new file mode 100644 index 00000000..b02a3390 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -0,0 +1,364 @@ +import { buildIOSSetupPlan } from "./plan.ts"; +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupStepStatus, + IOSValueResolution, +} from "./types.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; + +export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], +} as const; + +export type IOSNativeReadinessBundleIdentifier = + | { status: "resolved"; value: string } + | { status: "missing" } + | { status: "unresolved" } + | { status: "conflicting"; candidates: string[] }; + +export type IOSNativeReadinessAppIdPrefix = + | { status: "resolved"; source: "literal-entitlements"; value: string } + | { + status: "missing"; + source: "literal-entitlements"; + /** Literal values observed in only part of the selected target's configuration set. */ + candidates?: string[]; + } + | { + status: "conflicting"; + source: "literal-entitlements"; + candidates: string[]; + }; + +/** + * A human-only convenience value from Xcode signing configuration. This is + * never treated as proven App ID Prefix evidence because legacy Apple + * accounts can use a prefix that differs from DEVELOPMENT_TEAM. + */ +export type IOSUnverifiedAppIdPrefixSuggestion = { + source: "xcode-development-team"; + value: string; +}; + +export type IOSNativeReadinessTarget = + | { + status: "selected"; + projectPath: string; + targetId: string; + targetName: string; + bundleIdentifier: IOSNativeReadinessBundleIdentifier; + appIdPrefix: IOSNativeReadinessAppIdPrefix; + } + | { + status: "blocked"; + reason: "target-not-selected" | "selected-target-not-found"; + }; + +export type IOSAssociatedDomainAutomationBlockerCode = + | "target-not-selected" + | "expected-domain-unavailable" + | "manual-review-required" + | "generated-project" + | "missing-build-configurations" + | "unresolved-entitlements-path" + | "missing-or-unreadable-entitlements" + | "unresolved-associated-domains"; + +export interface IOSAssociatedDomainAutomationBlocker { + code: IOSAssociatedDomainAutomationBlockerCode; + message: string; +} + +export interface IOSAssociatedDomainReadiness { + /** The local status from the canonical iOS setup plan. */ + status: IOSSetupStepStatus; + /** Exact entitlement value derived from redacted publishable-key metadata. */ + expectedDomain?: string; + /** Existing, inspected XML entitlements files owned by the selected target. */ + files: string[]; + /** True only when a future writer has a complete, unambiguous local route. */ + automatable: boolean; + blockers: IOSAssociatedDomainAutomationBlocker[]; +} + +export interface IOSNativeReadinessAudit { + schemaVersion: 1; + kind: "clerk-ios-native-readiness"; + root: string; + target: IOSNativeReadinessTarget; + associatedDomain: IOSAssociatedDomainReadiness; + remote: { + status: "not-inspected"; + reason: "dry-run-does-not-read-remote-state"; + requirement: typeof IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT; + }; +} + +export interface BuildIOSNativeReadinessAuditOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; +} + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +function resolvedValues( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): string[] { + return [ + ...new Set( + target.configurations.flatMap((configuration) => { + const value = select(configuration); + return value.state === "resolved" ? [value.value] : []; + }), + ), + ].sort(); +} + +export function suggestAppIdPrefixFromDevelopmentTeam( + target: IOSAppTarget, +): IOSUnverifiedAppIdPrefixSuggestion | undefined { + if (target.configurations.length === 0) return undefined; + + const values = target.configurations.map((configuration) => configuration.developmentTeam); + if (values.some((value) => value.state !== "resolved")) return undefined; + + const candidates = [ + ...new Set(values.map((value) => (value.state === "resolved" ? value.value.trim() : ""))), + ]; + if (candidates.length !== 1 || !/^[A-Z0-9]{10}$/.test(candidates[0]!)) return undefined; + + return { source: "xcode-development-team", value: candidates[0]! }; +} + +function bundleIdentifier(target: IOSAppTarget): IOSNativeReadinessBundleIdentifier { + if (target.configurations.length === 0) return { status: "missing" }; + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "missing", + ) + ) { + return { status: "missing" }; + } + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "unresolved", + ) + ) { + return { status: "unresolved" }; + } + + const candidates = resolvedValues(target, (configuration) => configuration.bundleIdentifier); + if (candidates.length === 1) return { status: "resolved", value: candidates[0]! }; + if (candidates.length === 0) return { status: "missing" }; + return { status: "conflicting", candidates }; +} + +function appIdPrefix(target: IOSAppTarget): IOSNativeReadinessAppIdPrefix { + const candidates = [ + ...new Set( + target.configurations.flatMap((configuration) => { + const value = configuration.entitlements?.literalAppIdentifierPrefix; + return value == null ? [] : [value]; + }), + ), + ].sort(); + + if ( + candidates.length === 1 && + target.configurations.length > 0 && + target.configurations.every( + (configuration) => configuration.entitlements?.literalAppIdentifierPrefix === candidates[0], + ) + ) { + return { status: "resolved", source: "literal-entitlements", value: candidates[0]! }; + } + if (candidates.length > 1) { + return { status: "conflicting", source: "literal-entitlements", candidates }; + } + return { status: "missing", source: "literal-entitlements", candidates }; +} + +function targetIdentity( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, +): IOSNativeReadinessTarget { + if (inspection.selection.state !== "selected") { + return { status: "blocked", reason: "target-not-selected" }; + } + if (!target) return { status: "blocked", reason: "selected-target-not-found" }; + + return { + status: "selected", + projectPath: target.projectPath, + targetId: target.id, + targetName: target.name, + bundleIdentifier: bundleIdentifier(target), + appIdPrefix: appIdPrefix(target), + }; +} + +function associatedDomainReadiness( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, + associatedDomainPlan: IOSAssociatedDomainPlan | undefined, +): IOSAssociatedDomainReadiness { + const plan = buildIOSSetupPlan(inspection, { associatedDomainPlan }); + const planStep = plan.steps.find((step) => step.id === "add-associated-domain"); + const host = inspection.localPublishableKey.frontendApiHost; + const expectedDomain = host ? `webcredentials:${host}` : undefined; + const files = + associatedDomainPlan?.files.map((file) => file.path) ?? + (target + ? [ + ...new Set( + target.configurations.flatMap((configuration) => + configuration.entitlements ? [configuration.entitlements.path] : [], + ), + ), + ].sort() + : []); + const everyConfigurationHasExactDomain = + expectedDomain != null && + target != null && + target.configurations.length > 0 && + target.configurations.every((configuration) => + configuration.entitlements?.associatedDomains.some( + (domain) => domain.toLowerCase() === expectedDomain.toLowerCase(), + ), + ); + // The legacy planner accepts Apple's ?mode=developer suffix. Native setup + // automation intentionally requires the bare production-capable entry. + const status = associatedDomainPlan + ? associatedDomainPlan.status === "ready" + ? "required" + : associatedDomainPlan.status === "satisfied" + ? "satisfied" + : (planStep?.status ?? "blocked") + : planStep?.status === "satisfied" && !everyConfigurationHasExactDomain + ? "required" + : (planStep?.status ?? "blocked"); + const blockers: IOSAssociatedDomainAutomationBlocker[] = []; + const strictPlanOwnsLocalReadiness = + associatedDomainPlan?.status === "ready" || associatedDomainPlan?.status === "satisfied"; + + if (!target) { + blockers.push({ + code: "target-not-selected", + message: "Select exactly one iOS application target before editing entitlements.", + }); + } else if (!strictPlanOwnsLocalReadiness) { + if (target.configurations.length === 0) { + blockers.push({ + code: "missing-build-configurations", + message: "The selected target has no inspected build configurations.", + }); + } + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "resolved", + ) + ) { + blockers.push({ + code: "unresolved-entitlements-path", + message: "Resolve CODE_SIGN_ENTITLEMENTS for every selected-target configuration.", + }); + } + if (target.configurations.some((configuration) => configuration.entitlements == null)) { + blockers.push({ + code: "missing-or-unreadable-entitlements", + message: "Every selected-target configuration must use an existing XML entitlements file.", + }); + } + if ( + target.configurations.some( + (configuration) => + (configuration.entitlements?.unresolvedAssociatedDomains.length ?? 0) > 0, + ) + ) { + blockers.push({ + code: "unresolved-associated-domains", + message: "Resolve existing associated-domain build variables before editing entitlements.", + }); + } + } + + if (!expectedDomain && associatedDomainPlan?.requiresPublishableKey !== true) { + blockers.push({ + code: "expected-domain-unavailable", + message: "A proven local publishable key is required to derive the webcredentials domain.", + }); + } + if (inspection.generatedProject !== null) { + blockers.push({ + code: "generated-project", + message: `The Xcode project is owned by ${inspection.generatedProject}; update its source definition instead.`, + }); + } + if (status === "review") { + blockers.push({ + code: "manual-review-required", + message: "The canonical iOS setup plan requires review before this domain can be edited.", + }); + } + + const strictPlanBlockers = + associatedDomainPlan?.blockers.map((item) => ({ + code: "manual-review-required" as const, + message: item.message, + })) ?? []; + return { + status, + expectedDomain: associatedDomainPlan?.expectedDomain ?? expectedDomain, + files, + automatable: + associatedDomainPlan != null + ? associatedDomainPlan.status === "ready" && strictPlanBlockers.length === 0 + : status === "required" && blockers.length === 0, + blockers: [...strictPlanBlockers, ...blockers], + }; +} + +/** + * Builds a synchronous, serializable readiness snapshot without authentication, + * network access, or filesystem writes. Publishable-key values are never copied. + */ +export function buildIOSNativeReadinessAudit( + inspection: IOSProjectInspectionResult, + options: BuildIOSNativeReadinessAuditOptions = {}, +): IOSNativeReadinessAudit { + const target = selectedTarget(inspection); + return { + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: targetIdentity(inspection, target), + associatedDomain: associatedDomainReadiness(inspection, target, options.associatedDomainPlan), + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/native-remote.test.ts b/packages/cli-core/src/commands/init/ios/native-remote.test.ts new file mode 100644 index 00000000..882f9d6d --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -0,0 +1,633 @@ +import { describe, expect, test } from "bun:test"; +import { UserAbortError } from "../../../lib/errors.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; +import { + applyIOSNativeRemoteSetup, + buildIOSNativeRemotePlan, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, + type IOSNativeRemoteAPI, + type IOSNativeRemotePlan, + type IOSNativeRemotePrompts, +} from "./native-remote.ts"; +import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; + +const APPLICATION_ID = "app_native_test"; +const INSTANCE_ID = "ins_native_development"; +const BUNDLE_IDENTIFIER = "com.example.NativeApp"; +const LOCAL_PREFIX = "LEGACY1234"; +const EXPLICIT_PREFIX = "EXPLICIT12"; + +const captured = useCaptureLog(); + +function nativeSettings(apiEnabled: boolean): NativeSettings { + return { object: "native_settings", api_enabled: apiEnabled }; +} + +function registration( + appIdPrefix = LOCAL_PREFIX, + bundleId = BUNDLE_IDENTIFIER, + id = `iosapp_${appIdPrefix}`, +): IOSApplication { + return { + object: "ios_application", + id, + app_id_prefix: appIdPrefix, + bundle_id: bundleId, + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; +} + +function selectedTarget( + options: { + bundleIdentifier?: string; + appIdPrefix?: string | null; + appIdPrefixCandidates?: string[]; + } = {}, +): IOSNativeReadinessTarget { + const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; + return { + status: "selected", + projectPath: "NativeApp.xcodeproj", + targetId: "TARGET_NATIVE_APP", + targetName: "NativeApp", + bundleIdentifier: { + status: "resolved", + value: options.bundleIdentifier ?? BUNDLE_IDENTIFIER, + }, + appIdPrefix: + appIdPrefix == null + ? { + status: "missing", + source: "literal-entitlements", + ...(options.appIdPrefixCandidates ? { candidates: options.appIdPrefixCandidates } : {}), + } + : { status: "resolved", source: "literal-entitlements", value: appIdPrefix }, + }; +} + +function plan(options: { + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied"; + appIdPrefix?: string; +}): IOSNativeRemotePlan { + const appIdPrefix = options.appIdPrefix ?? LOCAL_PREFIX; + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: + options.nativeApi === "satisfied" && options.registration === "satisfied" + ? "satisfied" + : "ready", + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix, + nativeApi: options.nativeApi, + registration: options.registration, + actions: [ + ...(options.nativeApi === "required" + ? ["Enable the Native API for the linked development instance."] + : []), + ...(options.registration === "required" + ? [`Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${appIdPrefix}.`] + : []), + ], + blockers: [], + }; +} + +interface ScriptedAPIOptions { + nativeReads?: NativeSettings[]; + registrationReads?: IOSApplication[][]; + expectedAppIdPrefix?: string; + enable?: IOSNativeRemoteAPI["enableNativeApi"]; + create?: IOSNativeRemoteAPI["createIOSApplication"]; +} + +function scriptedAPI(options: ScriptedAPIOptions = {}): { + api: IOSNativeRemoteAPI; + calls: string[]; +} { + const calls: string[] = []; + const nativeReads = options.nativeReads ?? [nativeSettings(false)]; + const registrationReads = options.registrationReads ?? [[]]; + let nativeReadIndex = 0; + let registrationReadIndex = 0; + + const nextNativeSettings = () => + nativeReads[Math.min(nativeReadIndex++, nativeReads.length - 1)]!; + const nextRegistrations = () => + registrationReads[Math.min(registrationReadIndex++, registrationReads.length - 1)]!.map( + (item) => ({ ...item }), + ); + + return { + calls, + api: { + async getNativeSettings(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET native settings"); + return nextNativeSettings(); + }, + async listIOSApplications(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET iOS registrations"); + return nextRegistrations(); + }, + async enableNativeApi(applicationId, instanceId, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-native-api-"); + calls.push("PATCH native settings"); + if (options.enable) { + return options.enable(applicationId, instanceId, mutationOptions); + } + return nativeSettings(true); + }, + async createIOSApplication(applicationId, instanceId, params, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(params).toEqual({ + appIdPrefix: options.expectedAppIdPrefix ?? LOCAL_PREFIX, + bundleId: BUNDLE_IDENTIFIER, + }); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-registration-"); + calls.push("POST iOS registration"); + if (options.create) { + return options.create(applicationId, instanceId, params, mutationOptions); + } + return registration(params.appIdPrefix, params.bundleId); + }, + }, + }; +} + +function prepareOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + agent: false, + yes: true, + ...overrides, + }; +} + +function prompts( + options: { + appIdPrefix?: IOSNativeRemotePrompts["appIdPrefix"]; + confirmChanges?: () => Promise; + } = {}, +): IOSNativeRemotePrompts { + return { + appIdPrefix: + options.appIdPrefix ?? + (async () => { + throw new Error("unexpected App ID Prefix prompt"); + }), + confirmChanges: + options.confirmChanges ?? + (async () => { + throw new Error("unexpected remote-consent prompt"); + }), + }; +} + +describe("Clerk Native Application remote setup", () => { + test("validates the public App ID Prefix contract without assuming a Team ID shape", () => { + expect(validateAppIdPrefix(" legacy.prefix-value ")).toBe("legacy.prefix-value"); + expect(validateAppIdPrefix(" ")).toBeUndefined(); + expect(validateAppIdPrefix("x".repeat(256))).toBeUndefined(); + }); + + test("revalidates a satisfied plan without prompting or writing", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[exactRegistration]], + }); + + const result = await prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts(), + }); + + expect(result).toMatchObject({ + status: "satisfied", + nativeApi: "satisfied", + registration: "satisfied", + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: LOCAL_PREFIX, + actions: [], + blockers: [], + }); + await applyIOSNativeRemoteSetup(result, api); + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(captured.err).toContain("already configured"); + }); + + test.each([ + { + name: "Native API was disabled", + nativeReads: [nativeSettings(true), nativeSettings(false)], + registrationReads: [[registration()], [registration()]], + }, + { + name: "the exact iOS registration was deleted", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], []], + }, + { + name: "the exact iOS registration prefix changed", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], [registration(EXPLICIT_PREFIX)]], + }, + ])( + "fails closed without writing when $name after prepare", + async ({ nativeReads, registrationReads }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [...nativeReads], + registrationReads: registrationReads.map((items) => [...items]), + }); + const approved = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api, + prompts: prompts(), + }); + + await expect(applyIOSNativeRemoteSetup(approved, api)).rejects.toThrow( + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + ); + + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }, + ); + + test("uses an explicit prefix when the registration is missing", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + appIdPrefix: EXPLICIT_PREFIX, + agent: true, + }), + { api, prompts: prompts() }, + ); + + expect(result).toMatchObject({ + status: "ready", + nativeApi: "satisfied", + registration: "required", + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: EXPLICIT_PREFIX, + blockers: [], + }); + expect(result.actions).toEqual([ + `Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${EXPLICIT_PREFIX}.`, + ]); + }); + + test("asks a human for a missing App ID Prefix before asking for remote consent", async () => { + const promptOrder: string[] = []; + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + yes: false, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (_bundleIdentifier, suggested) => { + promptOrder.push("prefix"); + expect(suggested).toEqual({ + source: "partial-literal-entitlements", + value: LOCAL_PREFIX, + }); + return LOCAL_PREFIX; + }, + confirmChanges: async () => { + promptOrder.push("remote consent"); + return true; + }, + }), + }, + ); + + expect(result.status).toBe("ready"); + expect(result.appIdPrefix).toBe(LOCAL_PREFIX); + expect(promptOrder).toEqual(["prefix", "remote consent"]); + }); + + test("offers the unanimous Xcode Development Team but adopts only the human's choice", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (bundleIdentifier, suggested) => { + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + expect(suggested).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + return EXPLICIT_PREFIX; + }, + }), + }, + ); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + registration: "required", + }); + }); + + test("requires an explicit prefix in agent mode instead of prompting", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + agent: true, + }), + { api, prompts: prompts() }, + ), + ).rejects.toThrow("requires --app-id-prefix"); + }); + + test("blocks an explicit prefix that conflicts with a partial local candidate", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + requestedAppIdPrefix: EXPLICIT_PREFIX, + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual( + expect.objectContaining({ code: "app-id-prefix-conflict" }), + ); + }); + + test("adopts the sole existing registration prefix when local evidence is absent", () => { + const existing = registration(EXPLICIT_PREFIX); + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ appIdPrefix: null }), + nativeSettings: nativeSettings(false), + registrations: [existing], + }); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + nativeApi: "required", + registration: "satisfied", + blockers: [], + }); + }); + + test.each([ + { + name: "duplicate prefixes for one Bundle ID", + target: selectedTarget({ appIdPrefix: null }), + registrations: [registration(LOCAL_PREFIX), registration(EXPLICIT_PREFIX)], + blocker: "duplicate-bundle-registration", + }, + { + name: "an existing prefix that conflicts with the selected prefix", + target: selectedTarget(), + registrations: [registration(EXPLICIT_PREFIX)], + blocker: "app-id-prefix-conflict", + }, + ])("blocks $name", ({ target, registrations, blocker }) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target, + nativeSettings: nativeSettings(false), + registrations: [...registrations], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual(expect.objectContaining({ code: blocker })); + }); + + test("requires separate consent for the remote mutations", async () => { + let consentCalls = 0; + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(captured.err).toContain("remote Clerk changes"); + }); + + test("re-reads before writing and permits the approved action set to shrink", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + // Native API was enabled by another actor after consent. + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + + expect(calls).toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("blocks before writing when the pre-write re-read expands the approved action set", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + ).rejects.toThrow(); + + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("creates the iOS registration before enabling Native API", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + + expect(calls.indexOf("POST iOS registration")).toBeGreaterThan(-1); + expect(calls.indexOf("POST iOS registration")).toBeLessThan( + calls.indexOf("PATCH native settings"), + ); + }); + + test("reconciles an ambiguous registration-create error when the exact row now exists", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after create"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration], [exactRegistration]], + create: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after enable"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + enable: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("fails final verification when the approved remote postcondition is not present", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], []], + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api), + ).rejects.toThrow("did not pass the final verification"); + }); + + test("does not expose credential or publishable-key material in plans, output, or errors", async () => { + const sensitivePublishableKey = "pk_test_PUBLISHABLE_KEY_MUST_NOT_ESCAPE"; + const sensitiveBearer = "Bearer ak_API_TOKEN_MUST_NOT_ESCAPE"; + const settingsWithUnexpectedSecret = { + ...nativeSettings(false), + publishable_key: sensitivePublishableKey, + } as NativeSettings; + const { api: prepareAPI } = scriptedAPI({ + nativeReads: [settingsWithUnexpectedSecret], + registrationReads: [[]], + }); + + const prepared = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api: prepareAPI, + prompts: prompts(), + }); + expect(JSON.stringify(prepared)).not.toContain(sensitivePublishableKey); + expect(captured.err).not.toContain(sensitivePublishableKey); + + captured.clear(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], []], + create: async () => { + throw new Error(`request failed with ${sensitiveBearer}`); + }, + }); + + let thrown: unknown; + try { + await applyIOSNativeRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeDefined(); + expect(String(thrown)).not.toContain(sensitiveBearer); + expect(JSON.stringify(thrown)).not.toContain(sensitiveBearer); + expect(captured.err).not.toContain(sensitiveBearer); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts new file mode 100644 index 00000000..d1c98beb --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -0,0 +1,583 @@ +import { randomUUID } from "node:crypto"; +import { dim, yellow } from "../../../lib/color.ts"; +import { CliError, errorMessage, throwUsageError, throwUserAbort } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { select } from "../../../lib/listage.ts"; +import { + createIOSApplication, + enableNativeApi, + getNativeSettings, + listIOSApplications, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { confirm, text } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import type { + IOSNativeReadinessTarget, + IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; + +const APP_ID_PREFIX_MAX_LENGTH = 255; + +export type IOSNativeRemoteBlockerCode = + | "target-not-selected" + | "bundle-identifier-unavailable" + | "app-id-prefix-required" + | "app-id-prefix-conflict" + | "duplicate-bundle-registration"; + +export interface IOSNativeRemoteBlocker { + code: IOSNativeRemoteBlockerCode; + message: string; +} + +export type IOSNativeRemotePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-remote-setup"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + bundleIdentifier?: string; + appIdPrefix?: string; + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied" | "blocked"; + actions: string[]; + blockers: IOSNativeRemoteBlocker[]; +}; + +export interface IOSNativeRemoteAPI { + getNativeSettings(applicationId: string, instanceId: string): Promise; + enableNativeApi( + applicationId: string, + instanceId: string, + options: { idempotencyKey: string }, + ): Promise; + listIOSApplications(applicationId: string, instanceId: string): Promise; + createIOSApplication( + applicationId: string, + instanceId: string, + params: { appIdPrefix: string; bundleId: string }, + options: { idempotencyKey: string }, + ): Promise; +} + +const defaultAPI: IOSNativeRemoteAPI = { + getNativeSettings, + enableNativeApi, + listIOSApplications, + createIOSApplication, +}; + +export interface PrepareIOSNativeRemoteSetupOptions { + applicationId: string; + instanceId: string; + target: IOSNativeReadinessTarget; + appIdPrefix?: string; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + agent: boolean; + yes: boolean; +} + +export type IOSNativeRemoteAppIdPrefixSuggestion = + | IOSUnverifiedAppIdPrefixSuggestion + | { source: "partial-literal-entitlements"; value: string }; + +export interface IOSNativeRemotePrompts { + appIdPrefix( + bundleIdentifier: string, + suggested?: IOSNativeRemoteAppIdPrefixSuggestion, + ): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeRemotePrompts = { + appIdPrefix: async (bundleIdentifier, suggested) => { + if (suggested?.source === "xcode-development-team") { + const choice = await select({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + choices: [ + { + name: `Use ${suggested.value}`, + value: "use-suggested" as const, + description: + "Suggested from Xcode DEVELOPMENT_TEAM; usually matches, but legacy Apple accounts can differ.", + }, + { + name: "Enter a different App ID Prefix", + value: "enter-different" as const, + }, + ], + default: "use-suggested" as const, + }); + if (choice === "use-suggested") return suggested.value; + } + + return text({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + default: suggested?.source === "partial-literal-entitlements" ? suggested.value : undefined, + placeholder: suggested?.value ?? "ABCDE12345", + validate: (value) => + validateAppIdPrefix(value) ?? + `Enter an App ID Prefix between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters. Verify it in Apple Developer; it can differ from your Team ID.`, + }); + }, + confirmChanges: async () => + confirm({ message: "Apply these remote Clerk Native Application changes?", default: false }), +}; + +function blocker(code: IOSNativeRemoteBlockerCode, message: string): IOSNativeRemoteBlocker { + return { code, message }; +} + +export function validateAppIdPrefix(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && normalized.length <= APP_ID_PREFIX_MAX_LENGTH ? normalized : undefined; +} + +function localIdentity(target: IOSNativeReadinessTarget): { + bundleIdentifier?: string; + appIdPrefix?: string; + appIdPrefixCandidates: string[]; + blockers: IOSNativeRemoteBlocker[]; +} { + if (target.status !== "selected") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "target-not-selected", + "Select exactly one iOS application target before registering it with Clerk.", + ), + ], + }; + } + + if (target.bundleIdentifier.status !== "resolved") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID across every selected-target build configuration before registering the iOS app with Clerk.", + ), + ], + }; + } + + const appIdPrefixCandidates = + target.appIdPrefix.status === "resolved" + ? [target.appIdPrefix.value] + : target.appIdPrefix.status === "conflicting" + ? target.appIdPrefix.candidates + : (target.appIdPrefix.candidates ?? []); + const blockers: IOSNativeRemoteBlocker[] = []; + if (target.appIdPrefix.status === "conflicting") { + blockers.push( + blocker( + "app-id-prefix-conflict", + "The selected target contains conflicting literal App ID Prefix evidence across its build configurations.", + ), + ); + } + + return { + bundleIdentifier: target.bundleIdentifier.value, + appIdPrefix: target.appIdPrefix.status === "resolved" ? target.appIdPrefix.value : undefined, + appIdPrefixCandidates, + blockers, + }; +} + +export function buildIOSNativeRemotePlan(options: { + applicationId: string; + instanceId: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; + nativeSettings: NativeSettings; + registrations: IOSApplication[]; +}): IOSNativeRemotePlan { + const identity = localIdentity(options.target); + const blockers = [...identity.blockers]; + const bundleIdentifier = identity.bundleIdentifier; + const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); + if (options.requestedAppIdPrefix != null && !explicitPrefix) { + blockers.push( + blocker( + "app-id-prefix-required", + `The Apple App ID Prefix must contain between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters after trimming.`, + ), + ); + } + if ( + explicitPrefix && + identity.appIdPrefixCandidates.some((candidate) => candidate !== explicitPrefix) + ) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The supplied App ID Prefix does not match the literal prefix proven for ${bundleIdentifier ?? "the selected target"}.`, + ), + ); + } + + const matchingBundle = bundleIdentifier + ? options.registrations.filter((registration) => registration.bundle_id === bundleIdentifier) + : []; + const registeredPrefixes = [...new Set(matchingBundle.map((item) => item.app_id_prefix))].sort(); + const selectedPrefix = explicitPrefix ?? identity.appIdPrefix; + let appIdPrefix = selectedPrefix; + let registration: IOSNativeRemotePlan["registration"] = "blocked"; + + if (bundleIdentifier) { + if (selectedPrefix) { + const conflicts = registeredPrefixes.filter((prefix) => prefix !== selectedPrefix); + if (conflicts.length > 0) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `${bundleIdentifier} is already registered with a different App ID Prefix. Review the Native Applications page; clerk init will not replace it.`, + ), + ); + } else { + registration = registeredPrefixes.includes(selectedPrefix) ? "satisfied" : "required"; + } + } else if (registeredPrefixes.length === 1) { + appIdPrefix = registeredPrefixes[0]; + if (identity.appIdPrefixCandidates.some((candidate) => candidate !== appIdPrefix)) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The existing Clerk registration for ${bundleIdentifier} conflicts with literal App ID Prefix evidence in the selected target.`, + ), + ); + } else { + registration = "satisfied"; + } + } else if (registeredPrefixes.length > 1) { + blockers.push( + blocker( + "duplicate-bundle-registration", + `${bundleIdentifier} has more than one App ID Prefix registration. Review the Native Applications page before continuing.`, + ), + ); + } else { + blockers.push( + blocker( + "app-id-prefix-required", + `An Apple App ID Prefix is required to register ${bundleIdentifier}.`, + ), + ); + } + } + + const nativeApi = options.nativeSettings.api_enabled ? "satisfied" : "required"; + const actions: string[] = []; + if (registration === "required" && appIdPrefix && bundleIdentifier) { + actions.push( + `Register iOS Bundle ID ${bundleIdentifier} with Apple App ID Prefix ${appIdPrefix}.`, + ); + } + if (nativeApi === "required") { + actions.push("Enable the Native API for the linked development instance."); + } + + const status = + blockers.length > 0 + ? "blocked" + : nativeApi === "satisfied" && registration === "satisfied" + ? "satisfied" + : "ready"; + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + appIdPrefix, + nativeApi, + registration, + actions, + blockers, + }; +} + +async function readRemoteState( + applicationId: string, + instanceId: string, + api: IOSNativeRemoteAPI, +): Promise<{ nativeSettings: NativeSettings; registrations: IOSApplication[] }> { + const [nativeSettings, registrations] = await Promise.all([ + api.getNativeSettings(applicationId, instanceId), + api.listIOSApplications(applicationId, instanceId), + ]); + return { nativeSettings, registrations }; +} + +function formatBlockers(plan: IOSNativeRemotePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +export async function prepareIOSNativeRemoteSetup( + options: PrepareIOSNativeRemoteSetupOptions, + dependencies: { + api?: IOSNativeRemoteAPI; + prompts?: IOSNativeRemotePrompts; + } = {}, +): Promise { + const api = dependencies.api ?? defaultAPI; + const prompts = dependencies.prompts ?? defaultPrompts; + let state: Awaited>; + try { + state = await withSpinner("Auditing Clerk Native Application settings...", async () => + readRemoteState(options.applicationId, options.instanceId, api), + ); + } catch (error) { + log.debug(`Could not inspect Clerk Native Application settings: ${errorMessage(error)}`); + throw new CliError( + "Clerk Native Application settings could not be inspected. No local or remote setup changes were written; verify your application access and rerun clerk init.", + ); + } + let plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + ...state, + }); + + const onlyMissingPrefix = + plan.status === "blocked" && + plan.blockers.length === 1 && + plan.blockers[0]?.code === "app-id-prefix-required" && + options.appIdPrefix == null && + plan.bundleIdentifier != null; + if (onlyMissingPrefix) { + if (options.agent) { + throwUsageError( + `Registering ${plan.bundleIdentifier} in agent mode requires --app-id-prefix . Verify the App ID Prefix in Apple Developer, then rerun. No local or remote setup changes were written.`, + ); + } + const literalSuggestion = + options.target.status === "selected" && options.target.appIdPrefix.status === "missing" + ? options.target.appIdPrefix.candidates?.length === 1 + ? { + source: "partial-literal-entitlements" as const, + value: options.target.appIdPrefix.candidates[0]!, + } + : undefined + : undefined; + const appIdPrefix = await prompts.appIdPrefix( + plan.bundleIdentifier!, + literalSuggestion ?? options.unverifiedAppIdPrefixSuggestion, + ); + plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + target: options.target, + requestedAppIdPrefix: appIdPrefix, + ...state, + }); + } + + if (plan.status === "blocked") { + throw new CliError( + `Clerk Native Application readiness could not be completed safely. No local or remote setup changes were written:\n${formatBlockers(plan)}\n Review https://dashboard.clerk.com/~/native-applications`, + ); + } + + if (plan.status === "satisfied") { + log.info(dim("Clerk Native API and iOS application registration are already configured.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk changes:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n Remote changes are additive. clerk init will not update or delete an existing iOS registration.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing Clerk Native Application settings in agent mode requires explicit consent. Rerun with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +async function reconciledPlan( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI, +): Promise { + const state = await readRemoteState(plan.applicationId, plan.instanceId, api); + return buildIOSNativeRemotePlan({ + applicationId: plan.applicationId, + instanceId: plan.instanceId, + target: { + status: "selected", + projectPath: "", + targetId: "", + targetName: "", + bundleIdentifier: { status: "resolved", value: plan.bundleIdentifier! }, + appIdPrefix: plan.appIdPrefix + ? { status: "resolved", source: "literal-entitlements", value: plan.appIdPrefix } + : { status: "missing", source: "literal-entitlements", candidates: [] }, + }, + requestedAppIdPrefix: plan.appIdPrefix, + ...state, + }); +} + +function revalidatedActionSetIsAuthorized( + approved: IOSNativeRemotePlan, + current: IOSNativeRemotePlan, +): boolean { + if ( + current.status === "blocked" || + current.applicationId !== approved.applicationId || + current.instanceId !== approved.instanceId || + current.bundleIdentifier !== approved.bundleIdentifier || + current.appIdPrefix !== approved.appIdPrefix + ) { + return false; + } + // Concurrent completion is harmless. A newly-required action was never + // shown in the approved preview and must force a fresh plan instead. + if (approved.nativeApi === "satisfied" && current.nativeApi !== "satisfied") return false; + if (approved.registration === "satisfied" && current.registration !== "satisfied") { + return false; + } + return true; +} + +export async function applyIOSNativeRemoteSetup( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI = defaultAPI, +): Promise { + if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { + throw new CliError( + "The approved Clerk Native Application plan is incomplete. No remote changes were made; rerun clerk init.", + ); + } + + let currentPlan: IOSNativeRemotePlan; + try { + currentPlan = await withSpinner("Rechecking Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + log.debug(`Could not recheck Clerk Native Application settings: ${errorMessage(error)}`); + throw new CliError( + "Clerk Native Application settings could not be rechecked after the local setup. No remote changes were made; rerun clerk init.", + ); + } + if (!revalidatedActionSetIsAuthorized(plan, currentPlan)) { + throw new CliError( + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + ); + } + + const registrationIdempotencyKey = `clerk-init-ios-registration-${randomUUID()}`; + const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; + + // Register first so Native API is never enabled by this command without a + // matching iOS application registration already present. + if (currentPlan.registration === "required") { + try { + const created = await withSpinner("Registering the iOS application with Clerk...", async () => + api.createIOSApplication( + plan.applicationId, + plan.instanceId, + { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, + { idempotencyKey: registrationIdempotencyKey }, + ), + ); + if ( + created.bundle_id !== plan.bundleIdentifier || + created.app_id_prefix !== plan.appIdPrefix + ) { + throw new CliError( + "Clerk returned an unexpected iOS application registration. The local setup remains intact; rerun clerk init to reconcile remote state.", + ); + } + } catch (error) { + log.debug(`Could not create the iOS application registration: ${errorMessage(error)}`); + let registrations: IOSApplication[]; + try { + registrations = await api.listIOSApplications(plan.applicationId, plan.instanceId); + } catch (fallbackError) { + log.debug( + `Could not confirm the iOS application registration: ${errorMessage(fallbackError)}`, + ); + throw new CliError( + "The iOS application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", + ); + } + const exact = registrations.some( + (registration) => + registration.bundle_id === plan.bundleIdentifier && + registration.app_id_prefix === plan.appIdPrefix, + ); + if (!exact) { + throw new CliError( + "The iOS application could not be registered with Clerk. The local setup remains intact; rerun clerk init to retry safely.", + ); + } + } + log.success(`iOS application ${plan.bundleIdentifier} registered with Clerk`); + } + + if (currentPlan.nativeApi === "required") { + try { + const enabled = await withSpinner("Enabling the Clerk Native API...", async () => + api.enableNativeApi(plan.applicationId, plan.instanceId, { + idempotencyKey: nativeAPIIdempotencyKey, + }), + ); + if (!enabled.api_enabled) { + throw new CliError( + "Clerk did not report the Native API as enabled. The local setup and any completed registration remain intact; rerun clerk init.", + ); + } + } catch (error) { + log.debug(`Could not enable the Clerk Native API: ${errorMessage(error)}`); + let current: NativeSettings; + try { + current = await api.getNativeSettings(plan.applicationId, plan.instanceId); + } catch (fallbackError) { + log.debug(`Could not confirm Clerk Native API state: ${errorMessage(fallbackError)}`); + throw new CliError( + "Native API enablement could not be confirmed. The local setup and any completed iOS registration remain intact; rerun clerk init.", + ); + } + if (!current.api_enabled) { + throw new CliError( + "The Native API could not be enabled. The local setup and any completed iOS registration remain intact; rerun clerk init to retry safely.", + ); + } + } + log.success("Clerk Native API enabled for the development instance"); + } + + let finalPlan: IOSNativeRemotePlan; + try { + finalPlan = await withSpinner("Verifying Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + log.debug(`Could not verify Clerk Native Application settings: ${errorMessage(error)}`); + throw new CliError( + "Clerk Native Application settings could not be verified. The local setup and any completed remote changes remain intact; rerun clerk init.", + ); + } + if (finalPlan.status !== "satisfied" || !revalidatedActionSetIsAuthorized(plan, finalPlan)) { + throw new CliError( + "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.", + ); + } +} 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 00000000..a6247d03 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -0,0 +1,131 @@ +import type { IOSProjectInspectionResult, IOSSetupPlan, IOSSetupStepStatus } from "./types.ts"; +import { buildIOSNativeReadinessAudit, type IOSNativeReadinessAudit } from "./native-readiness.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; + +const STATUS_MARKER: Record = { + satisfied: "✓", + required: "○", + review: "!", + blocked: "×", +}; + +export interface IOSDryRunOutput { + schemaVersion: 1; + mode: "read-only"; + status: IOSSetupPlan["status"]; + inspection: IOSProjectInspectionResult; + plan: IOSSetupPlan; + nativeReadiness: IOSNativeReadinessAudit; +} + +export interface IOSOutputOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; +} + +export function createIOSDryRunOutput( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): IOSDryRunOutput { + return { + schemaVersion: 1, + mode: "read-only", + status: plan.status, + inspection, + plan, + nativeReadiness: buildIOSNativeReadinessAudit(inspection, options), + }; +} + +export function formatIOSSetupPlan( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): string { + const lines = ["", "iOS setup plan (read-only)", ` Root: ${inspection.root}`]; + + if (inspection.selection.state === "selected") { + lines.push( + ` Target: ${inspection.selection.targetName} (${inspection.selection.projectPath})`, + ); + } else if (inspection.selection.state === "ambiguous") { + lines.push(" Targets:"); + for (const candidate of inspection.selection.candidates) { + lines.push( + ` - ${candidate.targetName} [${candidate.targetId}] in ${candidate.projectPath}`, + ); + } + } + + const selection = inspection.selection; + const selected = + selection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + if (selected) { + const bundles = [ + ...new Set( + selected.configurations.flatMap((configuration) => + configuration.bundleIdentifier.state === "resolved" + ? [configuration.bundleIdentifier.value] + : [], + ), + ), + ]; + if (bundles.length > 0) lines.push(` Bundle ID: ${bundles.join(", ")}`); + lines.push( + ` ClerkKit: ${selected.packages.clerkKit}; ClerkKitUI: ${selected.packages.clerkKitUI}`, + ); + } + if (inspection.localPublishableKey.frontendApiHost) { + lines.push( + ` Publishable key: found (${inspection.localPublishableKey.instanceType}; ${inspection.localPublishableKey.frontendApiHost})`, + ); + } else { + const keyStatus = inspection.localPublishableKey.conflict + ? "conflicting local sources" + : inspection.localPublishableKey.candidateSources.length > 0 + ? "found but invalid" + : "not found"; + lines.push(` Publishable key: ${keyStatus}`); + } + + lines.push(""); + for (const item of plan.steps) { + lines.push(` ${STATUS_MARKER[item.status]} [${item.status}] ${item.title}`); + lines.push(` ${item.description}`); + if (item.automatable) lines.push(" `clerk init` can apply this step."); + for (const link of item.links ?? []) lines.push(` ${link.url}`); + } + + if (plan.diagnostics.length > 0) { + lines.push("", " Diagnostics:"); + for (const diagnostic of plan.diagnostics) { + lines.push(` - [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`); + if (diagnostic.remedy) lines.push(` ${diagnostic.remedy}`); + } + } + + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, options); + lines.push("", " Native iOS readiness:"); + lines.push( + ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, + ); + if (!nativeReadiness.associatedDomain.automatable) { + for (const blocker of nativeReadiness.associatedDomain.blockers) { + lines.push(` ${blocker.message}`); + } + } + lines.push( + " - Native API and Dashboard iOS registration: not inspected during this local-only dry-run. Regular `clerk init` audits and safely reconciles both on the linked development instance after authentication.", + ); + + lines.push( + "", + " No files, Xcode settings, Clerk applications, or remote resources were changed.", + ); + return lines.join("\n"); +} diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts new file mode 100644 index 00000000..82b67d4f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -0,0 +1,806 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { planIOSDirectConfig } from "./direct-config.ts"; +import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { formatIOSSetupPlan } from "./output.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { planIOSRuntimeKey } from "./runtime-key.ts"; +import { createIOSFixture } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function planFor(options: Parameters[1] = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + const inspection = await inspectIOSProject(root); + return buildIOSSetupPlan(inspection); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSSetupPlan", () => { + test("returns stable ordered steps without treating a project env key as runtime wiring", async () => { + const plan = await planFor({ complete: true }); + + expect(plan.steps.map((step) => step.id)).toEqual([ + "select-target", + "install-clerk-sdk", + "configure-publishable-key", + "inject-clerk-environment", + "wire-auth-callbacks", + "register-native-application", + "add-associated-domain", + "add-authentication-flow", + "verify-integration", + ]); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.filter((step) => step.automatable)).toEqual([]); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep?.status).toBe("review"); + expect(configureStep?.description).toContain("available to copy"); + const domainStep = plan.steps.find((step) => step.id === "add-associated-domain"); + expect(domainStep?.status).toBe("review"); + expect(domainStep?.description).toContain("not proven to be the selected target's runtime key"); + expect(plan.steps.find((step) => step.id === "register-native-application")?.status).toBe( + "review", + ); + expect(JSON.stringify(plan)).not.toContain("CLERK_PUBLISHABLE_KEY="); + }); + + test("satisfies configuration when a target LocalSecrets key has recognized loader wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.configureCalls).toEqual([ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "app-init", + localSecretsRuntimeBinding: "proven", + }, + ]); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("satisfies configuration and derives the domain from a redacted inline literal", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false }); + const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${publishableKey}") } + var body: some Scene { + WindowGroup { Text("Hello").environment(Clerk.shared) } + } +} +`, + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan.changes?.configuration).toBe("verify-existing"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.description).toContain( + "webcredentials:inline.clerk.example", + ); + expect(JSON.stringify(plan)).not.toContain(publishableKey); + }); + + test("marks safe fresh direct configuration and environment injection as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ + status: "ready", + changes: { + clerkKitImport: "insert", + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "required", + automatable: true, + }); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).toContain("directly"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(JSON.stringify(plan)).not.toContain("pk_test_"); + }); + + test("advertises a proven prebuilt AuthView scaffold without selecting it", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "--prebuilt-auth-ui", + ); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("uses the documented AuthView sheet without generating app-level callback code", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-selected-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")?.description).toContain( + "does not need generated app-level callback code", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "network-free local plan", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "only if Apple is enabled", + ); + }); + + test("blocks a selected AuthView scaffold when the SDK compatibility proof fails", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-old-prebuilt-sdk-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + const message = "ClerkKitUI's documented native components require clerk-ios 1.0.0 or newer."; + + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan: { + status: "blocked", + blockers: [{ code: "incompatible-sdk", message }], + }, + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + message, + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("blocks an explicitly requested scaffold over a partial existing auth flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-partial-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.authFlowReferences = [{ path: "MyApp/ContentView.swift" }]; + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "blocked", + sourcePath: "MyApp/ContentView.swift", + actions: [], + blockers: [ + { + code: "existing-auth-integration", + message: "An existing or partial authentication flow must be reviewed manually.", + }, + ], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "partial authentication flow", + ); + }); + + test("maps every native Apple entitlement plan state into the ordered setup plan", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-native-apple-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + for (const fixture of [ + { + status: "ready" as const, + actions: ["Add the Apple entitlement."], + blockers: [], + expectedStatus: "required", + automatable: true, + text: "exact Default value", + }, + { + status: "satisfied" as const, + actions: [], + blockers: [], + expectedStatus: "satisfied", + automatable: false, + text: "exact native Sign in with Apple entitlement", + }, + { + status: "blocked" as const, + actions: [], + blockers: [{ code: "unsupported-entitlements" as const, message: "Review this file." }], + expectedStatus: "blocked", + automatable: false, + text: "Review this file.", + }, + ]) { + const plan = buildIOSSetupPlan(inspection, { appleEntitlementPlan: fixture }); + const stepIndex = plan.steps.findIndex((step) => step.id === "enable-native-apple"); + const domainIndex = plan.steps.findIndex((step) => step.id === "add-associated-domain"); + const appleStep = plan.steps[stepIndex]; + + expect(stepIndex).toBeGreaterThan(-1); + expect(stepIndex).toBeLessThan(domainIndex); + expect(appleStep).toMatchObject({ + status: fixture.expectedStatus, + automatable: fixture.automatable, + }); + expect(appleStep?.description).toContain(fixture.text); + } + }); + + test("surfaces strict Associated Domains blockers instead of asking for a local key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + releaseEntitlements: false, + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + const associatedDomainPlan = await planIOSAssociatedDomain({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + deferToPublishableKey: true, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan, associatedDomainPlan }); + const domain = plan.steps.find((step) => step.id === "add-associated-domain"); + + expect(associatedDomainPlan.status).toBe("blocked"); + expect(domain).toMatchObject({ status: "review", automatable: false }); + expect(domain?.description).toContain( + "Some selected-target configurations have entitlements while others do not", + ); + expect(domain?.description).not.toContain("valid local publishable key is needed"); + }); + + test("renders strict direct-config blockers as actionable blocked steps", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + generated: "xcodegen", + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ status: "blocked" }); + expect(directConfigPlan.blockers.map((blocker) => blocker.code)).toContain("generated-project"); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep).toMatchObject({ status: "blocked", automatable: false }); + expect(configureStep?.description).toContain("XcodeGen"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("does not satisfy or automate LocalSecrets wiring from a same-file helper", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const source = await Bun.file(appPath).text(); + await Bun.write( + appPath, + source.replace( + 'init() { Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") }', + `init() {} + func unusedConfigureHelper() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") + }`, + ), + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const runtimeKeyPlan = await planIOSRuntimeKey({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan }); + + expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "proven", + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("reviews a target runtime key when the configure expression has unknown wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "unknown", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("satisfies configuration for a selected-target Run scheme and ProcessInfo wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + inspection.localPublishableKey.source = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; + inspection.localPublishableKey.candidateSources = [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + ]; + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "process-info-environment", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan.status).toBe("blocked"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("keeps non-runtime key sources as available-to-copy evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + for (const source of [ + ".env", + ".clerk/.tmp/keyless.json", + "CLERK_PUBLISHABLE_KEY environment variable", + ]) { + inspection.localPublishableKey.source = source; + const step = buildIOSSetupPlan(inspection).steps.find( + (candidate) => candidate.id === "configure-publishable-key", + ); + expect(step?.status).toBe("review"); + expect(step?.description).toContain("available to copy"); + } + }); + + test("reviews a malformed available-only key instead of treating it as runtime failure", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + await Bun.write(join(root, ".env"), "CLERK_PUBLISHABLE_KEY=not-a-key\n"); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.localPublishableKey).toMatchObject({ + found: false, + source: ".env", + invalidSources: [".env"], + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("offers to replace a malformed key in a proven selected-target runtime sink", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const runtimeKeyPlan = await planIOSRuntimeKey({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan, directConfigPlan }); + + expect(inspection.localPublishableKey).toMatchObject({ + found: false, + source: "MyApp/LocalSecrets.plist", + invalidSources: ["MyApp/LocalSecrets.plist"], + }); + expect(directConfigPlan.status).toBe("blocked"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "required", + automatable: true, + }); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).toContain("LocalSecrets.plist"); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).not.toContain("Automatic direct configuration stopped"); + }); + + test("does not automate a name-only LocalSecrets expression without an exact loader binding", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + enum LocalSecrets { static let key = "" } + @main struct MyApp: App { + init() { Clerk.configure(publishableKey: LocalSecrets.key) } + var body: some Scene { WindowGroup { Text("Hello") } } + }`, + ); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("reports genuinely missing Swift setup as required", async () => { + const plan = await planFor({ complete: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe( + "required", + ); + }); + + test("plans ClerkKitUI by default for an untouched target", async () => { + const plan = await planFor({ clerkSDK: false, complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("ClerkKit and ClerkKitUI"); + expect(sdkStep?.description).toContain("prebuilt AuthView"); + }); + + test("plans ClerkKitUI for a source-blank core-only graph from an earlier setup", async () => { + const plan = await planFor({ clerkSDK: "core-only", complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("already has ClerkKit"); + expect(sdkStep?.description).toContain("Link ClerkKitUI"); + }); + + test("plans only ClerkKit when existing source shows custom-flow intent", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, complete: false, includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("custom-flow intent"); + expect(sdkStep?.description).toContain("ClerkKitUI is not required"); + const authStep = plan.steps.find((step) => step.id === "add-authentication-flow"); + expect(authStep?.description).toContain("custom ClerkKit"); + expect(authStep?.description).not.toContain("ClerkKitUI"); + }); + + test("requires ClerkKitUI when selected-target source imports its prebuilt UI", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + const output = formatIOSSetupPlan(inspection, plan); + expect(output).toContain("`clerk init` can apply this step."); + expect(output.match(/`clerk init` can apply this step\./g)).toHaveLength(1); + }); + + test("repairs a declared but unlinked ClerkKitUI product without source imports", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "declared"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("declared"); + expect(sdkStep?.description).toContain("not linked"); + }); + + test("does not mark generated-project SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, generated: "xcodegen" }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("does not mark unattributed SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("reviews linked Clerk products when their package reference is unattributed", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + + const plan = buildIOSSetupPlan(inspection); + const step = plan.steps.find((candidate) => candidate.id === "install-clerk-sdk"); + + expect(step?.status).toBe("review"); + expect(step?.description).toContain("could not be verified as clerk-ios"); + }); + + test("treats missing Swift evidence as review when source membership is incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, clerkSDK: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.evidenceComplete = false; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "review", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + "cannot safely choose", + ); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe("review"); + }); + + test("reviews an existing configure call when no usable local key can be validated", async () => { + const plan = await planFor({ complete: true, includeKey: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("requires the bare domain when only Apple's developer-mode suffix is present", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.status).toBe("required"); + }); + + test("blocks all dependent steps when target selection is ambiguous", async () => { + const plan = await planFor({ secondTarget: true }); + + expect(plan.steps[0]?.status).toBe("blocked"); + expect(plan.steps.slice(1).every((step) => step.status === "blocked")).toBe(true); + }); + + test("includes usable choices when the requested target is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { secondTarget: true }); + const inspection = await inspectIOSProject(root, { target: "MissingApp" }); + + const plan = buildIOSSetupPlan(inspection); + + const selectStep = plan.steps.find((step) => step.id === "select-target"); + expect(selectStep?.status).toBe("blocked"); + expect(selectStep?.description).toContain("AdminApp"); + expect(selectStep?.description).toContain("MyApp"); + }); + + test("is deterministic for identical inspection input", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + expect(buildIOSSetupPlan(inspection)).toEqual(buildIOSSetupPlan(inspection)); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts new file mode 100644 index 00000000..a6d55b87 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -0,0 +1,644 @@ +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupPlan, + IOSSetupStep, + IOSSetupStepStatus, + IOSSourceEvidence, + IOSValueResolution, +} from "./types.ts"; +import { hasIOSDirectConfigCompatibility } from "./products.ts"; +import { clerkKitUIInstallDecision } from "./products.ts"; +import type { IOSDirectConfigPlan } from "./direct-config.ts"; +import type { IOSRuntimeKeyPlan } from "./runtime-key.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; +import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; +import type { IOSSDKInstallPlan } from "./install-sdk.ts"; + +const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; +const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; +const NATIVE_APPLE_URL = + "https://clerk.com/docs/ios/guides/configure/auth-strategies/sign-in-with-apple"; + +function associatedDomainMatches(actual: string, expected: string): boolean { + return actual.toLowerCase() === expected.toLowerCase(); +} + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +function selectedEvidence(target: IOSAppTarget | undefined): IOSSourceEvidence[] { + return target ? [{ path: target.projectPath, objectId: target.id }] : []; +} + +function distinctResolved( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): string[] { + return [ + ...new Set( + target.configurations + .map(select) + .filter( + (value): value is Extract => + value.state === "resolved", + ) + .map((value) => value.value), + ), + ].sort(); +} + +function allEvidence( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): IOSSourceEvidence[] { + return target.configurations.flatMap((configuration) => select(configuration).evidence); +} + +function step( + id: IOSSetupStep["id"], + title: string, + status: IOSSetupStepStatus, + description: string, + evidence: IOSSourceEvidence[] = [], + links?: IOSSetupStep["links"], + automatable = false, +): IOSSetupStep { + return { id, title, status, automatable, description, links, evidence }; +} + +function publishableKeyRuntimeSource( + source: string | undefined, + target: IOSAppTarget, +): "inline-literal" | "run-scheme" | "local-secrets" | "available-only" | undefined { + if (!source) return undefined; + if ( + target.swift.configureCalls.some( + (call) => call.path === source && call.publishableKeyWiring === "inline-literal", + ) + ) { + return "inline-literal"; + } + if (source.endsWith(".xcscheme")) return "run-scheme"; + if (target.runtimeKeySinks.some((sink) => sink.path === source)) { + return "local-secrets"; + } + return "available-only"; +} + +export function hasIOSRuntimeKeyHandoffShape( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, +): boolean { + const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => + source.endsWith(".xcscheme"), + ); + return ( + inspection.generatedProject === null && + target.swift.evidenceComplete && + target.swift.entryPoints.length === 1 && + target.swift.configureCalls.length === 1 && + target.swift.configureCalls[0]?.publishableKeyWiring === "local-secrets-loader" && + target.swift.configureCalls[0]?.localSecretsRuntimeBinding === "proven" && + target.swift.configureCalls[0]?.startupBinding === "app-init" && + target.swift.configureCalls[0]?.path === target.swift.entryPoints[0]?.path && + target.swift.localSecretsRuntimeBindings.length === 1 && + target.runtimeKeySinks.length === 1 && + !hasEnabledSchemeKey + ); +} + +export interface BuildIOSSetupPlanOptions { + /** Strict SDK/package compatibility from the same planner used by apply. */ + sdkInstallPlan?: Pick; + /** Strict, redacted file/Git readiness from the same planner used by apply. */ + runtimeKeyPlan?: Pick; + /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ + directConfigPlan?: IOSDirectConfigPlan; + /** Strict existing-entitlements readiness from the same planner used by apply. */ + associatedDomainPlan?: Pick< + IOSAssociatedDomainPlan, + | "status" + | "expectedDomain" + | "requiresPublishableKey" + | "blockers" + | "files" + | "missingEntitlementsSettings" + >; + /** Optional native Apple capability requested or already present locally. */ + appleEntitlementPlan?: Pick; + /** Strict source readiness for the optional prebuilt AuthView scaffold. */ + prebuiltAuthPlan?: Pick; + /** Whether this invocation explicitly selected the optional AuthView scaffold. */ + prebuiltAuthSelected?: boolean; +} + +export function buildIOSSetupPlan( + inspection: IOSProjectInspectionResult, + options: BuildIOSSetupPlanOptions = {}, +): IOSSetupPlan { + const target = selectedTarget(inspection); + const targetEvidence = selectedEvidence(target); + const steps: IOSSetupStep[] = []; + + steps.push( + step( + "select-target", + "Select the iOS application target", + target ? "satisfied" : "blocked", + target + ? `Using ${target.name} in ${target.projectPath}.` + : inspection.selection.state === "ambiguous" + ? "More than one iOS app target is eligible. Rerun with --target ; the CLI will not guess." + : inspection.selection.state === "not-found" + ? `The requested target "${inspection.selection.requested}" was not found.${inspection.selection.candidates.length > 0 ? ` Available targets: ${inspection.selection.candidates.join(", ")}.` : ""}` + : "No usable iOS application target was found.", + targetEvidence, + ), + ); + + if (!target) { + const blockedSteps: Array<[IOSSetupStep["id"], string]> = [ + ["install-clerk-sdk", "Install Clerk's iOS SDK"], + ["configure-publishable-key", "Configure Clerk"], + ["inject-clerk-environment", "Inject Clerk into SwiftUI"], + ["wire-auth-callbacks", "Wire authentication callbacks"], + ["register-native-application", "Register the native application"], + ["add-associated-domain", "Add the associated domain"], + ["add-authentication-flow", "Add an authentication flow"], + ["verify-integration", "Verify the integration"], + ]; + for (const [id, title] of blockedSteps) { + steps.push( + step(id, title, "blocked", "Select an iOS application target before planning this step."), + ); + } + return finishPlan(inspection, steps); + } + + const usesClerkKitUI = target.swift.importsClerkKitUI.length > 0; + const productDecision = clerkKitUIInstallDecision(target); + const includeClerkKitUI = + productDecision === "prebuilt" || + options.prebuiltAuthSelected === true || + options.prebuiltAuthPlan?.status === "satisfied"; + const sourceEntryPointIsAmbiguous = target.swift.status === "ambiguous"; + const requiredProductsLinked = + target.packages.clerkKit === "linked" && + (!includeClerkKitUI || target.packages.clerkKitUI === "linked"); + const packageIsVerified = + target.packages.package === "remote" || target.packages.package === "local"; + const strictSDKBlocked = options.sdkInstallPlan?.status === "blocked"; + const strictSDKBlocker = strictSDKBlocked + ? options.sdkInstallPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const sdkStatus: IOSSetupStepStatus = strictSDKBlocked + ? "blocked" + : productDecision === "unknown" + ? "review" + : !requiredProductsLinked + ? "required" + : packageIsVerified + ? "satisfied" + : target.packages.package === "unattributed" + ? "review" + : "required"; + const sdkAutomatable = + sdkStatus === "required" && + inspection.generatedProject === null && + target.packages.package !== "unattributed"; + steps.push( + step( + "install-clerk-sdk", + "Install Clerk's iOS SDK for the selected target", + sdkStatus, + strictSDKBlocked + ? `The selected Clerk iOS SDK cannot support this approved setup safely: ${strictSDKBlocker ?? "Update the clerk-ios package and rerun the plan."}` + : productDecision === "unknown" + ? `Swift source membership for ${target.name} is incomplete, so the CLI cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the source-membership diagnostics or make the product choice manually.` + : sdkStatus === "satisfied" + ? `ClerkKit is linked to ${target.name}${target.packages.clerkKitUI === "linked" ? "; ClerkKitUI is linked too" : ""}.` + : sdkStatus === "review" + ? `ClerkKit${target.packages.clerkKitUI === "linked" ? " and ClerkKitUI are" : " is"} linked to ${target.name}, but the package reference could not be verified as clerk-ios. Confirm the linked products come from Clerk's remote or local package.` + : includeClerkKitUI && target.packages.clerkKitUI !== "linked" + ? usesClerkKitUI + ? `${target.name} imports ClerkKitUI, but that product is not linked to the target. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package.` + : target.packages.clerkKitUI === "declared" + ? `ClerkKitUI is declared for ${target.name} but not linked in its Frameworks phase. Link it alongside ClerkKit.` + : target.packages.clerkKit !== "absent" + ? `${target.name} already has ClerkKit but no source-proven custom flow. Link ClerkKitUI from the same clerk-ios package so the prebuilt AuthView path is ready by default.` + : `${target.name} has no existing Clerk integration. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package so the prebuilt AuthView is ready by default.` + : includeClerkKitUI + ? `Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit and ClerkKitUI to ${target.name} for the fastest prebuilt AuthView path.` + : `${target.name} already shows core-only or custom-flow intent. Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit; ClerkKitUI is not required for that path.`, + targetEvidence, + undefined, + sdkAutomatable, + ), + ); + + const configured = target.swift.configureCalls.length > 0; + const usablePublishableKey = + inspection.localPublishableKey.found && + !inspection.localPublishableKey.conflict && + inspection.localPublishableKey.frontendApiHost != null; + const runtimeKeySource = publishableKeyRuntimeSource( + inspection.localPublishableKey.source, + target, + ); + const publishableKeySourceIsRuntime = + runtimeKeySource === "inline-literal" || + runtimeKeySource === "run-scheme" || + runtimeKeySource === "local-secrets"; + const configureCallConnectedToRuntime = + usablePublishableKey && + runtimeKeySource != null && + runtimeKeySource !== "available-only" && + target.swift.configureCalls.some( + (call) => + call.startupBinding === "app-init" && + (runtimeKeySource === "inline-literal" + ? call.publishableKeyWiring === "inline-literal" && + call.inlinePublishableKey?.state === "valid" + : runtimeKeySource === "local-secrets" + ? call.publishableKeyWiring === "local-secrets-loader" && + call.localSecretsRuntimeBinding === "proven" + : call.publishableKeyWiring === "process-info-environment"), + ); + const publishableKeyBlocked = + publishableKeySourceIsRuntime && + (inspection.localPublishableKey.conflict || + (!inspection.localPublishableKey.found && + inspection.localPublishableKey.invalidSources.length > 0)); + const localSecretsHandoff = hasIOSRuntimeKeyHandoffShape(inspection, target); + const needsLocalSecretsHandoff = localSecretsHandoff && !configureCallConnectedToRuntime; + const hasDirectConfigCompatibility = hasIOSDirectConfigCompatibility(inspection, target); + const directConfigPlanApplies = options.directConfigPlan != null && !hasDirectConfigCompatibility; + const directConfigAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.configuration !== "verify-existing"; + const directConfigBlocked = + directConfigPlanApplies && options.directConfigPlan?.status === "blocked"; + const directConfigBlocker = directConfigBlocked + ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const runtimeKeyAutomationReady = + needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "ready"; + const runtimeKeyBlocker = + needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "blocked" + ? options.runtimeKeyPlan.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const configuredStatus: IOSSetupStepStatus = needsLocalSecretsHandoff + ? options.runtimeKeyPlan?.status === "blocked" + ? "blocked" + : "required" + : publishableKeyBlocked + ? "blocked" + : directConfigBlocked + ? "blocked" + : configured + ? sourceEntryPointIsAmbiguous || !configureCallConnectedToRuntime + ? "review" + : "satisfied" + : directConfigAutomationReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "configure-publishable-key", + "Configure Clerk with a publishable key", + configuredStatus, + needsLocalSecretsHandoff + ? runtimeKeyAutomationReady + ? `Clerk.configure(publishableKey:) is connected to the selected target's proven LocalSecrets.plist loader, but that runtime source does not contain a usable key. clerk init can fetch the linked development instance's publishable key directly into that plist without printing it or creating an env file.` + : runtimeKeyBlocker + ? `Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but automatic key wiring is blocked: ${runtimeKeyBlocker}` + : "Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but that source has no usable key. Add the development key manually or run the strict iOS setup preflight before applying it." + : publishableKeyBlocked + ? inspection.localPublishableKey.conflict + ? "Multiple effective publishable-key sources point at different Clerk instances. Resolve the conflict before configuring the app." + : "The effective publishable-key source is malformed. Replace it before relying on Clerk.configure(...)." + : directConfigBlocked + ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` + : configured + ? sourceEntryPointIsAmbiguous + ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." + : configureCallConnectedToRuntime + ? runtimeKeySource === "inline-literal" + ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." + : "A Clerk.configure(...) call is connected to a recognized selected-target runtime key loader. The key expression and value are intentionally redacted from this plan." + : usablePublishableKey + ? runtimeKeySource === "available-only" + ? "A usable publishable key is available to copy, but the app is not proven to load it at runtime. Configure Clerk directly in the selected target's @main App initializer, or repair the app's existing runtime loader if it intentionally uses one." + : "A selected-target runtime publishable key is present, but the Clerk.configure(...) expression could not be connected to its loader. Confirm the wiring manually; the expression and value are intentionally redacted." + : "A Clerk.configure(...) call is present, but the inspector could not validate a usable selected-target runtime key source. Confirm the runtime value manually; the expression is intentionally redacted." + : !target.swift.evidenceComplete + ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." + : inspection.localPublishableKey.conflict + ? "Available publishable-key candidates point to different Clerk instances. Choose the intended development instance and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." + : inspection.localPublishableKey.invalidSources.length > 0 + ? "The available publishable-key candidate is malformed. Replace it with the intended development key and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." + : inspection.localPublishableKey.found + ? "A local publishable key is available, but it is not proven to configure this target. New projects should call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer; the plan will never print the key." + : directConfigAutomationReady + ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` + : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", + target.swift.configureCalls, + undefined, + runtimeKeyAutomationReady || directConfigAutomationReady, + ), + ); + + const injected = target.swift.environmentInjections.length > 0; + const requiresSwiftUIEnvironment = + target.swift.environmentConsumers.length > 0 || includeClerkKitUI || directConfigPlanApplies; + const directEnvironmentAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.environment === "insert"; + const directEnvironmentBlocked = !injected && requiresSwiftUIEnvironment && directConfigBlocked; + const injectedStatus: IOSSetupStepStatus = injected + ? sourceEntryPointIsAmbiguous + ? "review" + : "satisfied" + : directEnvironmentBlocked + ? "blocked" + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? "required" + : "review"; + steps.push( + step( + "inject-clerk-environment", + "Inject Clerk into the SwiftUI environment", + injectedStatus, + injected + ? sourceEntryPointIsAmbiguous + ? "Clerk.shared is injected, but multiple @main entry points make the shipping root ambiguous." + : "Clerk.shared is injected into SwiftUI." + : directEnvironmentBlocked + ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's WindowGroup root manually."}` + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? directEnvironmentAutomationReady + ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` + : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." + : requiresSwiftUIEnvironment + ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." + : "No target source was found consuming Clerk from SwiftUI's environment. Add `.environment(Clerk.shared)` only if AuthView or an `@Environment(Clerk.self)` view needs it.", + target.swift.environmentInjections, + undefined, + directEnvironmentAutomationReady, + ), + ); + + const handlesURLs = target.swift.openURLHandlers.length > 0; + const selectedPrebuiltAuthReady = + options.prebuiltAuthSelected === true && + options.prebuiltAuthPlan?.status === "ready" && + !strictSDKBlocked; + const prebuiltAuthHandlesItsOwnCallbacks = + selectedPrebuiltAuthReady || options.prebuiltAuthPlan?.status === "satisfied"; + steps.push( + step( + "wire-auth-callbacks", + "Wire authentication callbacks", + handlesURLs && !sourceEntryPointIsAmbiguous + ? "satisfied" + : prebuiltAuthHandlesItsOwnCallbacks && !sourceEntryPointIsAmbiguous + ? "satisfied" + : "review", + handlesURLs + ? "An onOpenURL handler forwards redirect URLs to Clerk." + : prebuiltAuthHandlesItsOwnCallbacks + ? "ClerkKitUI's AuthView handles its callback lifecycle while presented, so this quickstart flow does not need generated app-level callback code." + : "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk.", + target.swift.openURLHandlers, + undefined, + false, + ), + ); + + const bundleIdentifiers = distinctResolved( + target, + (configuration) => configuration.bundleIdentifier, + ); + const appPrefixes = [ + ...new Set( + target.configurations + .map((configuration) => configuration.entitlements?.literalAppIdentifierPrefix) + .filter((value): value is string => value != null), + ), + ].sort(); + const registrationBlocked = + target.configurations.length === 0 || + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state !== "resolved", + ) || + bundleIdentifiers.length !== 1; + steps.push( + step( + "register-native-application", + "Register the iOS app in Clerk Dashboard", + registrationBlocked ? "blocked" : "review", + registrationBlocked + ? "A single Bundle ID could not be resolved across build configurations. Make it explicit or consistent before registering the app." + : appPrefixes.length === 1 + ? `The source entitlements contain the literal App ID Prefix candidate ${appPrefixes[0]} for ${bundleIdentifiers[0]}. Confirm it in Apple Developer, then verify the app is registered and Native API is enabled. Dashboard state is not changed or assumed by dry-run.` + : `Verify that ${bundleIdentifiers[0]} is registered and Native API is enabled. Supply the Apple App ID Prefix from the Developer portal; DEVELOPMENT_TEAM is not assumed to be the prefix.`, + allEvidence(target, (configuration) => configuration.bundleIdentifier), + [{ kind: "dashboard", url: NATIVE_APPLICATIONS_URL }], + ), + ); + + if (options.appleEntitlementPlan) { + const appleStatus: IOSSetupStepStatus = + options.appleEntitlementPlan.status === "ready" + ? "required" + : options.appleEntitlementPlan.status === "satisfied" + ? "satisfied" + : "blocked"; + const description = + options.appleEntitlementPlan.status === "ready" + ? "Add the native Sign in with Apple entitlement with the exact Default value. After authentication, clerk init will separately audit and enable the matching Clerk Apple connection without requesting hosted/web Apple credentials." + : options.appleEntitlementPlan.status === "satisfied" + ? "The selected target has the exact native Sign in with Apple entitlement. Regular clerk init will verify the matching Clerk Apple connection after authentication." + : `Native Sign in with Apple needs review: ${options.appleEntitlementPlan.blockers.map((item) => item.message).join(" ")}`; + steps.push( + step( + "enable-native-apple", + "Enable native Sign in with Apple", + appleStatus, + description, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + [{ kind: "documentation", url: NATIVE_APPLE_URL }], + options.appleEntitlementPlan.status === "ready", + ), + ); + } + + const expectedDomain = inspection.localPublishableKey.frontendApiHost + ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` + : undefined; + const expectedDomainIsSelectedTargetRuntime = + runtimeKeySource === "inline-literal" || + runtimeKeySource === "run-scheme" || + runtimeKeySource === "local-secrets"; + const entitlements = target.configurations + .map((configuration) => configuration.entitlements) + .filter((value) => value != null); + const allEntitlementsPresent = + entitlements.length === target.configurations.length && entitlements.length > 0; + const domainPresent = + expectedDomain != null && + allEntitlementsPresent && + entitlements.every((value) => + value.associatedDomains.some((domain) => associatedDomainMatches(domain, expectedDomain)), + ); + const hasUnresolvedAssociatedDomains = entitlements.some( + (value) => value.unresolvedAssociatedDomains.length > 0, + ); + const associatedDomainPlan = options.associatedDomainPlan; + const associatedDomainStatus: IOSSetupStepStatus = + associatedDomainPlan?.status === "ready" + ? "required" + : associatedDomainPlan?.status === "satisfied" + ? "satisfied" + : associatedDomainPlan?.status === "blocked" + ? "review" + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? "review" + : domainPresent + ? "satisfied" + : expectedDomain && allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? "review" + : expectedDomain + ? "required" + : "blocked"; + const associatedDomainDescription = + associatedDomainPlan?.status === "ready" + ? associatedDomainPlan.expectedDomain + ? associatedDomainPlan.missingEntitlementsSettings + ? `Create and attach ${associatedDomainPlan.files[0]?.path ?? "an entitlements file"} only to iPhone and iPad builds, then add ${associatedDomainPlan.expectedDomain}. clerk init can apply this safely.` + : `Add ${associatedDomainPlan.expectedDomain} to every selected-target entitlements configuration. clerk init can apply the exact existing-file edits safely.` + : associatedDomainPlan.missingEntitlementsSettings + ? `The selected target has one safe synchronized destination for a new entitlements file. clerk init will create and attach it only to iPhone and iPad builds, then add the linked development application's exact webcredentials host without exposing the publishable key.` + : "The existing selected-target entitlements files are safe to edit. clerk init will derive the exact webcredentials host from the linked development application after authentication and add it without exposing the publishable key." + : associatedDomainPlan?.status === "blocked" + ? `Automatic Associated Domains setup needs review: ${associatedDomainPlan.blockers.map((blocker) => blocker.message).join(" ")}` + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? domainPresent + ? `${expectedDomain} matches every inspected entitlements configuration, but the key is only available to copy and is not proven to be the selected target's runtime key. Confirm the runtime key before treating this domain as final.` + : `The available key candidate maps to ${expectedDomain}, but it is not proven to be the selected target's runtime key. Wire or confirm the runtime key before adding its Associated Domain.` + : domainPresent + ? `${expectedDomain} is present in every inspected entitlements configuration.` + : expectedDomain + ? allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? `Some associated-domain values use unresolved build settings. Confirm they expand to ${expectedDomain} in every selected-target configuration.` + : `Enable Associated Domains for ${target.name} and add ${expectedDomain} to every selected-target entitlements configuration.` + : inspection.localPublishableKey.conflict + ? "Local publishable-key sources point at different Clerk instances, so the associated domain cannot be chosen safely. Resolve the key conflict and rerun this plan." + : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; + steps.push( + step( + "add-associated-domain", + "Add Clerk's associated domain", + associatedDomainStatus, + associatedDomainDescription, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + undefined, + associatedDomainPlan?.status === "ready", + ), + ); + + const hasAuthFlow = target.swift.authFlowReferences.length > 0; + const prebuiltAuthReady = options.prebuiltAuthPlan?.status === "ready" && !strictSDKBlocked; + const prebuiltAuthSatisfied = options.prebuiltAuthPlan?.status === "satisfied"; + const selectedPrebuiltAuthBlocked = + options.prebuiltAuthSelected === true && + (options.prebuiltAuthPlan?.status === "blocked" || strictSDKBlocked); + const authFlowStatus: IOSSetupStepStatus = selectedPrebuiltAuthBlocked + ? "blocked" + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "review" + : "satisfied" + : prebuiltAuthReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "add-authentication-flow", + "Add an authentication flow", + authFlowStatus, + selectedPrebuiltAuthBlocked + ? `The prebuilt AuthView scaffold was requested, but this app is not safe to rewrite automatically: ${strictSDKBlocker ?? options.prebuiltAuthPlan?.blockers.map((blocker) => blocker.message).join(" ") ?? "Review the existing signed-out route and integrate AuthView manually."} Linked AuthView providers are not inspected by this network-free local plan.` + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "A Clerk authentication flow is referenced, but multiple @main entry points make the shipping route ambiguous." + : prebuiltAuthSatisfied + ? "ClerkKitUI's documented UserButton entry and AuthView sheet are already configured in target source." + : "A Clerk authentication UI or sign-in/sign-up flow is referenced in target source." + : prebuiltAuthReady + ? options.prebuiltAuthSelected + ? `Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to ${options.prebuiltAuthPlan?.sourcePath ?? "the proven placeholder SwiftUI view"}. Linked AuthView providers are not inspected by this network-free local plan; regular clerk init will add or verify the local Sign in with Apple entitlement only if Apple is enabled for the linked instance.` + : `This target's pristine placeholder is eligible for the optional prebuilt AuthView scaffold. Run clerk init with --prebuilt-auth-ui or select it when prompted; existing application UI is never replaced automatically.` + : target.swift.evidenceComplete + ? productDecision === "core-only" + ? "Complete the custom ClerkKit sign-in/sign-up flow and route signed-out users to it." + : "Present ClerkKitUI's AuthView or build a custom ClerkKit sign-in/sign-up flow, then route signed-out users to it." + : "No Clerk authentication flow was found in the safely inspected source subset. Confirm the signed-out route manually.", + target.swift.authFlowReferences, + undefined, + prebuiltAuthReady, + ), + ); + + const actionable = steps.some((item) => item.status === "required" || item.status === "blocked"); + steps.push( + step( + "verify-integration", + "Build and verify sign-in", + "review", + actionable + ? "After completing the required steps, build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled." + : "The local evidence looks complete. Build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled.", + targetEvidence, + [{ kind: "documentation", url: QUICKSTART_URL }], + ), + ); + + return finishPlan(inspection, steps); +} + +function finishPlan(inspection: IOSProjectInspectionResult, steps: IOSSetupStep[]): IOSSetupPlan { + const summary: IOSSetupPlan["summary"] = { + satisfied: 0, + required: 0, + review: 0, + blocked: 0, + }; + for (const item of steps) summary[item.status]++; + const status: IOSSetupPlan["status"] = + summary.blocked > 0 ? "blocked" : summary.required > 0 ? "action-required" : "ready"; + + return { + schemaVersion: 1, + kind: "clerk-ios-setup", + root: inspection.root, + status, + selection: inspection.selection, + summary, + steps, + diagnostics: inspection.diagnostics, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts new file mode 100644 index 00000000..ca64ba4d --- /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 00000000..d34d387e --- /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 00000000..0bb4da67 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -0,0 +1,403 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { afterEach, describe, expect, test } from "bun:test"; +import type { PbxObjects } from "./pbx.ts"; +import { + applyIOSPrebuiltAuth, + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const CONTENT_FILE_ID = "616161616161616161616161"; +const CONTENT_BUILD_FILE_ID = "626262626262626262626262"; +const SHARED_CONTENT_BUILD_FILE_ID = "636363636363636363636363"; + +const APP_SOURCE = `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`; + +const CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +async function createFixture(options: { shared?: boolean; crlf?: boolean } = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: true, + includeKey: false, + secondTarget: options.shared === true, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + if (options.shared) { + (objects[IOS_FIXTURE_IDS.secondSourcesPhase]!.files as string[]).push( + SHARED_CONTENT_BUILD_FILE_ID, + ); + objects[SHARED_CONTENT_BUILD_FILE_ID] = { + isa: "PBXBuildFile", + fileRef: CONTENT_FILE_ID, + }; + } + await writeFile(projectPath, buildPbxProject(project)); + await writeFile(join(root, "MyApp", "MyAppApp.swift"), APP_SOURCE); + const content = options.crlf ? CONTENT_SOURCE.replace(/\n/g, "\r\n") : CONTENT_SOURCE; + await writeFile(join(root, "MyApp", "ContentView.swift"), content); + return root; +} + +function options(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + allowDirty: true, + } as const; +} + +async function updateDeploymentTargets( + root: string, + update: (settings: Record, configurationId: string) => void, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[configurationId]?.buildSettings; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error(`Missing fixture build settings for ${configurationId}.`); + } + update(settings as Record, configurationId); + } + await writeFile(projectPath, buildPbxProject(project)); +} + +describe("prebuilt AuthView source setup", () => { + test("plans only an exact target-owned untouched SwiftUI placeholder", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status: "ready", + appSourcePath: "MyApp/MyAppApp.swift", + sourcePath: "MyApp/ContentView.swift", + blockers: [], + }); + expect(JSON.stringify(plan)).not.toContain("AuthView()"); + expect(JSON.stringify(plan)).not.toContain("Hello, world!"); + }); + + test.each([ + { + name: "one selected configuration below iOS 17", + update(settings: Record, configurationId: string) { + settings.IPHONEOS_DEPLOYMENT_TARGET = + configurationId === IOS_FIXTURE_IDS.targetDebug ? "17.0" : "16.4"; + }, + }, + { + name: "an unresolved deployment target", + update(settings: Record) { + settings.IPHONEOS_DEPLOYMENT_TARGET = "$(PRIVATE_IOS_MINIMUM)"; + }, + }, + { + name: "conflicting device and simulator deployment targets", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]"] = "17.0"; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]"] = "16.0"; + }, + }, + { + name: "a missing deployment target", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + }, + }, + ])("blocks $name with fixed guidance and no source write", async ({ update }) => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const sourceBefore = await readFile(sourcePath); + await updateDeploymentTargets(root, update); + + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toEqual([ + { + code: "incompatible-deployment-target", + message: + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + }, + ]); + expect(JSON.stringify(plan)).not.toContain("PRIVATE_IOS_MINIMUM"); + expect(result.status).toBe("blocked"); + expect(await readFile(sourcePath)).toEqual(sourceBefore); + }); + + test("writes the documented AuthView presentation and is byte-idempotent", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await chmod(sourcePath, 0o640); + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + const source = await readFile(sourcePath, "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toBe(GENERATED_CONTENT_SOURCE); + expect(source).not.toContain("@Environment"); + expect(source).not.toContain(".onOpenURL"); + expect(source).not.toContain("clerk.auth.events"); + expect(source).not.toContain("clerk.session?.tasks"); + expect(source).not.toContain(".alert("); + expect(source).not.toContain("#Preview"); + expect((await Bun.file(sourcePath).stat()).mode & 0o777).toBe(0o640); + + const rerun = await planIOSPrebuiltAuth(options(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSPrebuiltAuth(rerun)).status).toBe("satisfied"); + expect(await readFile(sourcePath, "utf8")).toBe(source); + }); + + test("preserves CRLF and the existing Xcode header", async () => { + const root = await createFixture({ crlf: true }); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const plan = await planIOSPrebuiltAuth(options(root)); + expect((await applyIOSPrebuiltAuth(plan)).status).toBe("applied"); + const source = await readFile(sourcePath, "utf8"); + + expect(source.startsWith("//\r\n// ContentView.swift\r\n// MyApp\r\n//\r\n\r\n")).toBe(true); + expect(source.includes("\r\n")).toBe(true); + expect(/(^|[^\r])\n/.test(source)).toBe(false); + }); + + test("refuses customized UI instead of replacing it", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await writeFile( + sourcePath, + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Customer dashboard")'), + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("missing-placeholder"); + expect(await readFile(sourcePath, "utf8")).toContain("Customer dashboard"); + }); + + test("refuses source shared with another target", async () => { + const root = await createFixture({ shared: true }); + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("returns the replanned source blocker without exposing a concurrent edit", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile( + join(root, "MyApp", "ContentView.swift"), + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Concurrent edit")'), + ); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "missing-placeholder" }), + ); + expect(JSON.stringify(prepared)).not.toContain("Concurrent edit"); + }); + + test("returns the replanned blocker before comparing stale source identity", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile(join(root, "Project.swift"), "import ProjectDescription\n"); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "generated-project" }), + ); + }); + + test("accepts the direct-configured app root without touching it", async () => { + const root = await createFixture(); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const encodedHost = Buffer.from("example.clerk.accounts.dev$").toString("base64"); + await writeFile( + appPath, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "pk_test_${encodedHost}") + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("ready"); + expect(plan.appSourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test("requires the exact ContentView root to belong to the shipping SwiftUI App", async () => { + const root = await createFixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI + +@main +struct MyApp { + static func main() {} +} + +struct DecoyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-app-structure"); + }); + + test("refuses a source shared with a project below the normal discovery depth", async () => { + const root = await createFixture(); + const deepRoot = join(root, "a", "b", "c", "d"); + await mkdir(deepRoot, { recursive: true }); + await createIOSFixture(deepRoot, { + clerkSDK: false, + includeKey: false, + }); + + const projectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "../../../../../MyApp/ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + await writeFile(projectPath, buildPbxProject(project)); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("fails closed when exhaustive container discovery reaches its safety bound", async () => { + const root = await createFixture(); + const beyondBound = Array.from({ length: 26 }, (_, index) => `level-${index}`).reduce( + (directory, component) => join(directory, component), + root, + ); + await mkdir(beyondBound, { recursive: true }); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("incomplete-source-membership"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts new file mode 100644 index 00000000..453b2211 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -0,0 +1,804 @@ +import { lstat, readFile } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; +import { hasExactIOSSwiftUIAppContentRoot } from "./direct-config.ts"; +import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import type { IOSBuildConfiguration } from "./types.ts"; + +const MAX_SWIFT_FILE_BYTES = 1_000_000; + +export interface IOSPrebuiltAuthPlanOptions { + root: string; + projectPath: string; + targetId: string; + allowDirty?: boolean; +} + +export type IOSPrebuiltAuthBlockerCode = + | "invalid-selection" + | "target-not-found" + | "generated-project" + | "incompatible-deployment-target" + | "incomplete-source-membership" + | "ambiguous-entry-point" + | "unsupported-app-structure" + | "missing-placeholder" + | "shared-source" + | "unreadable-source" + | "unsupported-encoding" + | "unsupported-line-endings" + | "existing-auth-integration" + | "existing-authentication-flow" + | "runtime-prerequisites" + | "dirty-source" + | "git-state-unknown"; + +export interface IOSPrebuiltAuthBlocker { + code: IOSPrebuiltAuthBlockerCode; + message: string; +} + +/** A redacted, serializable semantic source plan. */ +export interface IOSPrebuiltAuthPlan { + schemaVersion: 1; + kind: "clerk-ios-prebuilt-auth"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + allowDirty: boolean; + appSourcePath?: string; + expectedAppSourceHash?: string; + sourcePath?: string; + expectedSourceHash?: string; + actions: string[]; + blockers: IOSPrebuiltAuthBlocker[]; +} + +/** @internal Candidate bytes are hidden from ordinary serialization. */ +export interface IOSPrebuiltAuthFileMutation { + absolutePath: string; + expectedHash: string; + candidateHash: string; + mode: number; + originalBytes: Uint8Array; + candidateBytes: Uint8Array; +} + +export type PreparedIOSPrebuiltAuthMutation = + | { + status: "ready"; + plan: IOSPrebuiltAuthPlan; + mutation: IOSPrebuiltAuthFileMutation; + } + | { + status: "satisfied" | "blocked" | "stale"; + plan: IOSPrebuiltAuthPlan; + message?: string; + mutation?: undefined; + }; + +export interface IOSPrebuiltAuthApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSPrebuiltAuthPlan; + message?: string; +} + +interface SourceSnapshot { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + source: string; + hash: string; + mode: number; + device: number; + inode: number; + newline: "\n" | "\r\n"; +} + +interface PreparedPlan { + plan: IOSPrebuiltAuthPlan; + appSnapshot?: SourceSnapshot; + sourceSnapshot?: SourceSnapshot; + sourceHeader?: string; +} + +const preparedValidators = new WeakMap Promise>(); + +function makePlan( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + status: IOSPrebuiltAuthPlan["status"], + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + | "appSourcePath" + | "expectedAppSourceHash" + | "sourcePath" + | "expectedSourceHash" + | "actions" + | "blockers" + > + > = {}, +): IOSPrebuiltAuthPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status, + root, + projectPath, + targetId: options.targetId, + allowDirty: options.allowDirty === true, + appSourcePath: details.appSourcePath, + expectedAppSourceHash: details.expectedAppSourceHash, + sourcePath: details.sourcePath, + expectedSourceHash: details.expectedSourceHash, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + code: IOSPrebuiltAuthBlockerCode, + message: string, + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + "appSourcePath" | "expectedAppSourceHash" | "sourcePath" | "expectedSourceHash" + > + > = {}, +): PreparedPlan { + return { + plan: makePlan(options, root, projectPath, "blocked", { + ...details, + blockers: [{ code, message }], + }), + }; +} + +function newlineStyle(source: string): "\n" | "\r\n" | undefined { + if (/\r(?!\n)/.test(source)) return undefined; + const hasCRLF = source.includes("\r\n"); + const hasBareLF = /(^|[^\r])\n/.test(source); + if (hasCRLF && hasBareLF) return undefined; + return hasCRLF ? "\r\n" : "\n"; +} + +function decodeUTF8(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return undefined; + } +} + +async function sourceSnapshot( + root: string, + relativePath: string, +): Promise { + const absolutePath = resolve(root, relativePath); + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) return undefined; + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SWIFT_FILE_BYTES) { + return undefined; + } + const bytes = new Uint8Array(await readFile(absolutePath)); + const source = decodeUTF8(bytes); + if (source == null || source.includes("\0")) return undefined; + const newline = newlineStyle(source); + if (!newline) return undefined; + return { + absolutePath, + relativePath, + bytes, + source, + hash: hashIOSFileBytes(bytes), + mode: info.mode & 0o7777, + device: info.dev, + inode: info.ino, + newline, + }; + } catch { + return undefined; + } +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [markerPath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, markerPath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +function splitHeader(source: string): { header: string; body: string } | undefined { + const importMatch = /^[\t ]*import[\t ]+(?:ClerkKit|ClerkKitUI|SwiftUI)[\t ]*$/m.exec(source); + if (importMatch?.index == null) return undefined; + const header = source.slice(0, importMatch.index); + const validHeader = header + .split(/\r?\n/) + .every((line) => line.trim() === "" || line.trimStart().startsWith("//")); + if (!validHeader || header.includes("/*")) return undefined; + return { header, body: source.slice(importMatch.index) }; +} + +function compactSwift(source: string): string | undefined { + let result = ""; + let inString = false; + let escaped = false; + for (let cursor = 0; cursor < source.length; cursor += 1) { + const character = source[cursor] ?? ""; + if (inString) { + result += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (!/\s/.test(character)) { + result += character; + } + } + return inString ? undefined : result; +} + +function supportsPrebuiltAuthDeploymentTarget(value: string): boolean { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim()); + if (!match) return false; + const components = match.slice(1).map((component) => Number(component ?? "0")); + if (components.some((component) => !Number.isSafeInteger(component))) return false; + return (components[0] ?? 0) >= 17; +} + +function targetSupportsPrebuiltAuth(configurations: IOSBuildConfiguration[]): boolean { + return ( + configurations.length > 0 && + configurations.every( + (configuration) => + configuration.deploymentTarget.state === "resolved" && + supportsPrebuiltAuthDeploymentTarget(configuration.deploymentTarget.value), + ) + ); +} + +const PRISTINE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const SIMPLE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, world!") + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_VIEW = `import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const pristineForms = new Set( + [PRISTINE_CONTENT_VIEW, SIMPLE_CONTENT_VIEW].map((source) => compactSwift(source)), +); +const generatedForm = compactSwift(GENERATED_CONTENT_VIEW); + +function classifyContentView(source: string): { + kind: "pristine" | "generated" | "other"; + header?: string; +} { + const split = splitHeader(source); + if (!split || split.body.includes("//") || split.body.includes("/*")) return { kind: "other" }; + const compact = compactSwift(split.body); + if (compact != null && compact === generatedForm) + return { kind: "generated", header: split.header }; + if (compact != null && pristineForms.has(compact)) + return { kind: "pristine", header: split.header }; + return { kind: "other" }; +} + +async function gitDirtyState( + root: string, + absolutePath: string, +): Promise<"clean" | "dirty" | "not-repository" | "unknown"> { + try { + const child = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", absolutePath], + { cwd: root, stdout: "pipe", stderr: "ignore" }, + ); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode === 0) return output.trim() === "" ? "clean" : "dirty"; + const probe = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { + cwd: root, + stdout: "ignore", + stderr: "ignore", + }); + return (await probe.exited) === 0 ? "unknown" : "not-repository"; + } catch { + return "unknown"; + } +} + +async function sourceIdentityOccurrences( + memberships: Awaited>, + snapshot: SourceSnapshot, +): Promise { + let occurrences = 0; + try { + for (const membership of memberships) { + if (!membership.complete) return undefined; + for (const file of membership.files) { + const info = await lstat(file.absolutePath); + if (!info.isFile() || info.isSymbolicLink()) return undefined; + if (info.dev === snapshot.device && info.ino === snapshot.inode) occurrences += 1; + } + } + return occurrences; + } catch { + return undefined; + } +} + +async function preparePlan(options: IOSPrebuiltAuthPlanOptions): Promise { + const root = resolve(options.root); + const absoluteProjectPath = resolve(root, options.projectPath); + if ( + !options.targetId || + !options.projectPath || + resolve(root, relative(root, absoluteProjectPath)) !== absoluteProjectPath || + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) + ) { + return blocked( + options, + root, + options.projectPath, + "invalid-selection", + "The selected Xcode project or target is invalid.", + ); + } + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const inspection = await inspectIOSProject(root, { target: options.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target could not be proven.", + ); + } + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (generator != null) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated Swift sources.`, + ); + } + const target = inspection.appTargets.find( + (candidate) => candidate.id === options.targetId && candidate.projectPath === projectPath, + ); + if (!target) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target disappeared during inspection.", + ); + } + if (!targetSupportsPrebuiltAuth(target.configurations)) { + return blocked( + options, + root, + projectPath, + "incompatible-deployment-target", + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + ); + } + if (!target.swift.evidenceComplete) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "The selected target's complete Swift source membership could not be proven.", + ); + } + if (target.swift.entryPoints.length !== 1 || !target.swift.entryPoints[0]?.path) { + return blocked( + options, + root, + projectPath, + "ambiguous-entry-point", + "The selected target must contain exactly one shipping @main Swift entry point.", + ); + } + const appSourcePath = target.swift.entryPoints[0].path; + const appSnapshot = await sourceSnapshot(root, appSourcePath); + if (!appSnapshot) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "The selected @main Swift source is not a safe, readable in-root regular file.", + { appSourcePath }, + ); + } + const appDetails = { + appSourcePath, + expectedAppSourceHash: appSnapshot.hash, + }; + if (!hasExactIOSSwiftUIAppContentRoot(appSnapshot.source)) { + return blocked( + options, + root, + projectPath, + "unsupported-app-structure", + "The shipping WindowGroup must have one direct ContentView root before the optional prebuilt UI can be added.", + appDetails, + ); + } + + const memberships = await inspectIOSSourceMembership(root); + const selectedMembership = memberships.find( + (membership) => + membership.targetId === options.targetId && membership.projectPath === projectPath, + ); + if (!selectedMembership?.complete || memberships.some((membership) => !membership.complete)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete source ownership across every local native target could not be proven.", + appDetails, + ); + } + const contentCandidates = selectedMembership.files.filter( + (file) => + basename(file.absolutePath) === "ContentView.swift" && + dirname(file.absolutePath) === dirname(appSnapshot.absolutePath), + ); + if (contentCandidates.length !== 1 || !contentCandidates[0]) { + return blocked( + options, + root, + projectPath, + "missing-placeholder", + "The selected target does not have one separate target-owned ContentView.swift beside its @main source.", + appDetails, + ); + } + const sourcePath = contentCandidates[0].relativePath; + const sourceSnapshotValue = await sourceSnapshot(root, sourcePath); + if (!sourceSnapshotValue) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "ContentView.swift is not a safe, readable in-root regular UTF-8 source file.", + { ...appDetails, sourcePath }, + ); + } + const sourceDetails = { + ...appDetails, + sourcePath, + expectedSourceHash: sourceSnapshotValue.hash, + }; + const identityOccurrences = await sourceIdentityOccurrences(memberships, sourceSnapshotValue); + if (identityOccurrences !== 1) { + return blocked( + options, + root, + projectPath, + "shared-source", + "ContentView.swift is shared, aliased, or not exclusively owned by the selected target.", + sourceDetails, + ); + } + + const classification = classifyContentView(sourceSnapshotValue.source); + if (classification.kind === "generated") { + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "satisfied", { + ...sourceDetails, + actions: [ + `Verify ClerkKitUI's prebuilt UserButton and AuthView presentation in ${sourcePath}.`, + "Verify Clerk images are prefetched for the prebuilt authentication UI.", + ], + }), + }; + } + if (classification.kind !== "pristine") { + return blocked( + options, + root, + projectPath, + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 + ? "existing-authentication-flow" + : "missing-placeholder", + "Existing or customized application UI was preserved. Integrate AuthView manually in the app's signed-out route.", + sourceDetails, + ); + } + if ( + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 || + target.swift.environmentConsumers.length > 0 + ) { + return blocked( + options, + root, + projectPath, + "existing-authentication-flow", + "Existing Clerk authentication source was preserved instead of layering a second prebuilt flow over it.", + sourceDetails, + ); + } + if (!options.allowDirty) { + const dirty = await gitDirtyState(root, sourceSnapshotValue.absolutePath); + if (dirty === "dirty") { + return blocked( + options, + root, + projectPath, + "dirty-source", + `The planned Swift source ${sourcePath} has existing Git changes; pass the explicit dirty-file override to include it.`, + sourceDetails, + ); + } + if (dirty === "unknown") { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + `Git state for the planned Swift source ${sourcePath} could not be verified.`, + sourceDetails, + ); + } + } + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "ready", { + ...sourceDetails, + actions: [ + `Replace only the untouched SwiftUI placeholder in ${sourcePath} with ClerkKitUI's documented UserButton and AuthView presentation.`, + "Present AuthView from UserButton's signed-out content.", + "Prefetch Clerk images for the prebuilt authentication UI.", + ], + }), + }; +} + +export async function planIOSPrebuiltAuth( + options: IOSPrebuiltAuthPlanOptions, +): Promise { + return (await preparePlan(options)).plan; +} + +function mutationWithHiddenBytes( + snapshot: SourceSnapshot, + candidateBytes: Uint8Array, +): IOSPrebuiltAuthFileMutation { + const mutation = { + absolutePath: snapshot.absolutePath, + expectedHash: snapshot.hash, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: snapshot.mode, + } as IOSPrebuiltAuthFileMutation; + Object.defineProperties(mutation, { + originalBytes: { value: snapshot.bytes, enumerable: false }, + candidateBytes: { value: candidateBytes, enumerable: false }, + }); + return mutation; +} + +function readyPrepared( + plan: IOSPrebuiltAuthPlan, + mutation: IOSPrebuiltAuthFileMutation, + validator: () => Promise, +): PreparedIOSPrebuiltAuthMutation { + const prepared = { status: "ready", plan } as PreparedIOSPrebuiltAuthMutation; + Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + preparedValidators.set(prepared, validator); + return prepared; +} + +export async function prepareIOSPrebuiltAuthMutation( + plan: IOSPrebuiltAuthPlan, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-prebuilt-auth" || + !plan.appSourcePath || + !plan.expectedAppSourceHash || + !plan.sourcePath || + !plan.expectedSourceHash + ) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [ + { + code: "invalid-selection", + message: "The prebuilt AuthView source plan is incomplete or unsupported.", + }, + ], + }, + }; + } + const current = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: plan.allowDirty, + }); + if (current.plan.status === "blocked" || !current.sourceSnapshot) { + return { status: "blocked", plan: current.plan }; + } + if ( + current.plan.appSourcePath !== plan.appSourcePath || + current.plan.sourcePath !== plan.sourcePath || + current.plan.expectedAppSourceHash !== plan.expectedAppSourceHash || + current.plan.expectedSourceHash !== plan.expectedSourceHash + ) { + return { + status: "stale", + plan, + message: "The selected Swift sources changed after the preview.", + }; + } + if (current.plan.status === "satisfied") return { status: "satisfied", plan: current.plan }; + + const newline = current.sourceSnapshot.newline; + const generated = `${current.sourceHeader ?? ""}${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; + const candidateBytes = new TextEncoder().encode(generated); + const mutation = mutationWithHiddenBytes(current.sourceSnapshot, candidateBytes); + const candidateHash = mutation.candidateHash; + return readyPrepared(plan, mutation, async () => { + const verified = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return ( + verified.plan.status === "satisfied" && + verified.plan.sourcePath === plan.sourcePath && + verified.plan.expectedSourceHash === candidateHash + ); + }); +} + +export async function validatePreparedIOSPrebuiltAuth( + prepared: PreparedIOSPrebuiltAuthMutation, +): Promise { + return (await preparedValidators.get(prepared)?.()) ?? false; +} + +function asExistingMutation(mutation: IOSPrebuiltAuthFileMutation): IOSExistingFileMutation { + return { + path: mutation.absolutePath, + originalBytes: mutation.originalBytes, + originalHash: mutation.expectedHash, + candidateBytes: mutation.candidateBytes, + candidateHash: mutation.candidateHash, + mode: mutation.mode, + }; +} + +export async function applyIOSPrebuiltAuth( + plan: IOSPrebuiltAuthPlan, +): Promise { + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + if (prepared.status !== "ready") return prepared; + const result = await applyIOSFileTransaction( + [asExistingMutation(prepared.mutation)], + [async () => validatePreparedIOSPrebuiltAuth(prepared)], + ); + if (result.status === "applied") return { status: "applied", plan }; + if (result.status === "stale") { + return { + status: "stale", + plan, + message: "The selected Swift source changed while the approved update was being committed.", + }; + } + return { + status: "rolled-back", + plan, + message: "The AuthView source update failed validation and the original file was restored.", + }; +} diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts index 472c9f9b..f5c564d2 100644 --- a/packages/cli-core/src/commands/init/strategy.test.ts +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -21,6 +21,7 @@ import { bootstrapMod, keylessMod, keylessTargetMod, + plapiMod, } from "../../test/lib/init-harness.ts"; import * as promptsMod from "../../lib/prompts.ts"; import { init } from "./index.ts"; @@ -243,7 +244,9 @@ describe("init strategy", () => { test("agent mode with --login while unauthenticated throws a usage error", async () => { setup({ isAgent: true, email: null }); - await expect(init({ login: true })).rejects.toThrow(/--login requires an interactive terminal/); + await expect(init({ login: true })).rejects.toThrow( + /--login requires authentication.*interactively/, + ); expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); expect(loginMod.login).not.toHaveBeenCalled(); @@ -313,6 +316,9 @@ describe("init strategy", () => { test("agent mode with keyless framework and --app uses real app flow", async () => { setup({ isAgent: true, email: "user@example.com" }); mockExistingProject(KEYLESS_CTX); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_abc" } } as never); mockMiddlewareScaffold(); await init({ app: "app_abc" }); @@ -356,19 +362,48 @@ describe("init strategy", () => { expect(captured.err).toContain("clerk init --app "); }); - test("agent mode with real app target and no auth launches login", async () => { + test("authenticated agent iOS setup without an app target prints native runtime guidance", async () => { + const { captured } = setup({ isAgent: true, email: "user@example.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + envFile: ".env", + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "MyApp/MyAppApp.swift", content: "", description: "" }], + postInstructions: [], + }); + + await init({ yes: true }); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(captured.err).toContain("clerk init --app "); + expect(captured.err).toContain("Clerk.configure(publishableKey:"); + expect(captured.err).toContain(".environment(Clerk.shared)"); + expect(captured.err).toContain("LocalSecrets loaders remain supported compatibility paths"); + expect(captured.err).not.toContain("clerk env pull"); + }); + + test("agent mode with real app target and no auth fails before interactive login", async () => { setup({ isAgent: true }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); - await init({ app: "app_abc" }); + await expect(init({ app: "app_abc" })).rejects.toThrow("--app requires authentication"); - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalledWith({ - skipIfLinked: true, - app: "app_abc", - cwd: FAKE_CTX.cwd, - createIfMissing: expect.any(String), - }); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); }); test("-y flag triggers login when unauthenticated", async () => { @@ -727,14 +762,14 @@ describe("init strategy", () => { spyOn(heuristics, "isAuthenticated").mockResolvedValue(true); await expect(init({ login: true })).rejects.toThrow( - /--login requires an interactive terminal/, + /--login requires authentication.*interactively/, ); expect(loginMod.login).not.toHaveBeenCalled(); expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); }); - test("a real CLERK_PLATFORM_API_KEY is trusted outright, without needing to validate a stored session", async () => { - process.env.CLERK_PLATFORM_API_KEY = "test_key"; + test("a Platform API key is trusted only after read-only PLAPI validation", async () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test_agent_validation"; try { setup({ isAgent: true, email: null }); mockExistingProject(KEYLESS_CTX); @@ -743,6 +778,7 @@ describe("init strategy", () => { await init({}); + expect(plapiMod.listApplications).toHaveBeenCalled(); expect(linkMod.link).toHaveBeenCalled(); expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); } finally { diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 044cb81d..8a0d0fb9 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -166,7 +166,9 @@ describe("link", () => { await runLink({ app: "app_123" }); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -220,7 +222,9 @@ describe("link", () => { await runLink({ app: "app_123" }); expect(mockConfirm).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalled(); }); @@ -264,6 +268,25 @@ describe("link", () => { expect(mockAutolink).toHaveBeenCalled(); expect(mockCreateApplication).not.toHaveBeenCalled(); }); + + test("can skip ambient-key autolink for a native runtime plan", async () => { + mockIsAgent.mockReturnValue(true); + mockAutolink.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "", appId: "app_web", instances: { development: "ins_web" } }, + }); + mockCreateApplication.mockResolvedValue({ ...mockApp, application_id: "app_native" }); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ createIfMissing: "native-project", skipAutolink: true }); + + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockCreateApplication).toHaveBeenCalledWith("native-project"); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_native" }), + ); + }); }); describe("already linked", () => { @@ -351,7 +374,9 @@ describe("link", () => { await runLink({ skipIfLinked: true, app: "app_123" }); expect(mockConfirm).toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalled(); }); }); @@ -404,7 +429,9 @@ describe("link", () => { expect(mockListApplications).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); }); test("shows interactive picker when no --app flag", async () => { @@ -434,6 +461,33 @@ describe("link", () => { expect(mockFetchApplication).not.toHaveBeenCalled(); }); + test("skipAutolink bypasses ambient key detection and uses the interactive picker", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockResolvedValue([mockApp]); + mockFindClerkKeys.mockResolvedValue([ + { key: "pk_test", source: "CLERK_PUBLISHABLE_KEY env var" }, + ]); + mockMatchKeyToApp.mockReturnValue({ + app: mockApp, + instance: mockApp.instances[0], + source: "CLERK_PUBLISHABLE_KEY env var", + }); + mockSearch.mockResolvedValue("app_123"); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ skipAutolink: true }); + + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockFindClerkKeys).not.toHaveBeenCalled(); + expect(mockMatchKeyToApp).not.toHaveBeenCalled(); + expect(mockSearch).toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + "github.com/org/repo", + expect.objectContaining({ appId: "app_123" }), + ); + }); + test("source returns create option first, then all choices, when term is empty", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); @@ -1047,7 +1101,9 @@ describe("link", () => { expect(mockFindClerkKeys).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); }); test("shows target app name in re-link prompt when --app is provided", async () => { diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index e9872bf9..6db3f993 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -26,6 +26,12 @@ interface LinkOptions { * interactive end-to-end. */ createIfMissing?: string; + /** + * Skip generic process-env/dotenv key discovery when those sources are not + * runtime inputs for the calling framework. Native iOS direct setup uses + * this so a web app's ambient key cannot silently choose the embedded app. + */ + skipAutolink?: boolean; } export async function link(options: LinkOptions = {}): Promise { @@ -45,7 +51,7 @@ export async function link(options: LinkOptions = {}): Promise { return; } - if (!existing && !options.app && (options.skipIfLinked || agent)) { + if (!existing && !options.app && !options.skipAutolink && (options.skipIfLinked || agent)) { const autolinked = await autolink(cwd); if (autolinked) return; } @@ -75,13 +81,16 @@ export async function link(options: LinkOptions = {}): Promise { await ensureAuth(); const app = options.app - ? await withApiContext(fetchApplication(options.app), "Failed to fetch application") + ? await withApiContext( + fetchApplication(options.app, { includeSecretKeys: false }), + "Failed to fetch application", + ) : agent && options.createIfMissing ? await withApiContext( createApplication(options.createIfMissing), "Failed to create application", ) - : await resolveApp(cwd, displayPath, !existing); + : await resolveApp(cwd, displayPath, !existing && !options.skipAutolink); const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); @@ -159,7 +168,7 @@ async function handleExistingProfile( if (options.app) { await ensureAuth(); const targetApp = await withApiContext( - fetchApplication(options.app), + fetchApplication(options.app, { includeSecretKeys: false }), "Failed to fetch application", ); return confirm({ message: `Re-link to ${cyan(appLabel(targetApp))}?`, default: false }); diff --git a/packages/cli-core/src/lib/config-instance.ts b/packages/cli-core/src/lib/config-instance.ts new file mode 100644 index 00000000..478682a0 --- /dev/null +++ b/packages/cli-core/src/lib/config-instance.ts @@ -0,0 +1,61 @@ +import { CliError, ERROR_CODE } from "./errors.ts"; +import type { Application, ApplicationInstance } from "./plapi.ts"; + +export const INSTANCE_ALIASES: Record = { + dev: "development", + development: "development", + prod: "production", + production: "production", +}; + +export function resolveFetchedApplicationInstance( + appId: string, + app: Application, + instance?: string, +): + | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } + | { found: false; instanceId: string; instanceLabel: string } { + if (instance) { + const environment = INSTANCE_ALIASES[instance]; + if (environment) { + const matched = app.instances.find((entry) => entry.environment_type === environment); + if (!matched) { + throw new CliError(`No ${environment} instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: environment, + }; + } + + const matched = app.instances.find((entry) => entry.instance_id === instance); + if (matched) { + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + // Downstream guardrails key off the environment label when it is available. + instanceLabel: matched.environment_type || instance, + }; + } + + return { found: false, instanceId: instance, instanceLabel: instance }; + } + + const development = app.instances.find((entry) => entry.environment_type === "development"); + if (!development) { + throw new CliError(`No development instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; +} diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 41943b99..022805f3 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -11,7 +11,8 @@ import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; import { CliError, ERROR_CODE } from "./errors.ts"; import { withHomeFsAccess } from "./host-execution.ts"; import { log } from "./log.ts"; -import type { Application, ApplicationInstance } from "./plapi.ts"; +import { INSTANCE_ALIASES, resolveFetchedApplicationInstance } from "./config-instance.ts"; +export { resolveFetchedApplicationInstance } from "./config-instance.ts"; let overrideConfigFile: string | undefined; @@ -308,13 +309,6 @@ export async function resolveProfile(cwd: string): Promise< return undefined; } -const INSTANCE_ALIASES: Record = { - dev: "development", - development: "development", - prod: "production", - production: "production", -}; - export function resolveInstanceId(profile: Profile, flag?: string): { id: string; label: string } { if (!flag) { return { id: profile.instances.development, label: "development" }; @@ -339,64 +333,6 @@ interface AppContextOptions { cwd?: string; } -export function resolveFetchedApplicationInstance( - appId: string, - app: Application, - instance?: string, -): - | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } - | { found: false; instanceId: string; instanceLabel: string } { - if (instance) { - const env = INSTANCE_ALIASES[instance]; - if (env) { - const matched = app.instances.find((entry) => entry.environment_type === env); - if (!matched) { - throw new CliError(`No ${env} instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - instanceLabel: env, - }; - } - - const matched = app.instances.find((entry) => entry.instance_id === instance); - if (matched) { - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - // Label by environment type, not the raw id — downstream guardrails - // (e.g. the production impersonation warning) key off this label. - instanceLabel: matched.environment_type || instance, - }; - } - - return { - found: false, - instanceId: instance, - instanceLabel: instance, - }; - } - - const development = app.instances.find((entry) => entry.environment_type === "development"); - if (!development) { - throw new CliError(`No development instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - - return { - found: true, - instance: development, - instanceId: development.instance_id, - instanceLabel: "development", - }; -} - /** * Resolve app context from explicit flags or linked profile. * This is the isomorphic resolution chain used by profile-dependent commands: diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 61f22481..ce2b98e2 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 00000000..730150ee --- /dev/null +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { credentialStoreStubs, stubFetch } from "../test/lib/stubs.ts"; + +const mockGetValidToken = mock(); +mock.module("./credential-store.ts", () => ({ + ...credentialStoreStubs, + getValidToken: (...args: unknown[]) => mockGetValidToken(...args), +})); + +const { createIOSApplication, enableNativeApi, getNativeSettings, listIOSApplications } = + await import("./plapi.ts"); +const { PlapiError } = await import("./errors.ts"); + +describe("PLAPI native application client", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_client_token"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + test("gets native settings for an environment alias", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: false }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await getNativeSettings("app_abc", "development"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_settings", + ); + expect(capturedHeaders?.get("Authorization")).toBe("Bearer ak_test_client_token"); + expect(capturedHeaders?.get("Accept")).toBe("application/json"); + expect(capturedHeaders?.has("Idempotency-Key")).toBe(false); + expect(result).toEqual(responseBody); + }); + + test("enables Native API with an idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: true }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await enableNativeApi("app_abc", "ins_dev_123", { + idempotencyKey: "enable-native-api-123", + }); + + expect(capturedMethod).toBe("PATCH"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_settings", + ); + expect(JSON.parse(capturedBody)).toEqual({ api_enabled: true }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("enable-native-api-123"); + expect(result).toEqual(responseBody); + }); + + test("lists the public iOS application DTOs", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const responseBody = [ + { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }, + ]; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await listIOSApplications("app_abc", "ins_dev_123"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_applications/ios", + ); + expect(result).toEqual(responseBody); + expect(result[0]).not.toHaveProperty("team_id"); + }); + + test("creates an iOS application with the public field names and idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 201 }); + }); + + const result = await createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_applications/ios", + ); + expect(JSON.parse(capturedBody)).toEqual({ + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("create-ios-app-123"); + expect(result).toEqual(responseBody); + }); + + test("preserves typed PLAPI errors from native endpoints without credential data", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ errors: [{ code: "resource_not_found" }] }), { status: 404 }), + ); + + try { + await getNativeSettings("app_missing", "development"); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(PlapiError); + expect((error as InstanceType).status).toBe(404); + expect(JSON.stringify(error)).not.toContain("ak_test_client_token"); + } + }); +}); diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8..c7a51154 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -252,6 +252,22 @@ describe("plapi", () => { expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); }); + test("sends If-Match when a config version is supplied", async () => { + let capturedHeaders: Headers | undefined; + stubFetch(async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await patchInstanceConfig( + "app_1", + "ins_1", + { connection_oauth_apple: { enabled: true } }, + { ifMatch: "v1_12345678" }, + ); + expect(capturedHeaders?.get("If-Match")).toBe("v1_12345678"); + }); + test("sends JSON body", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { @@ -293,7 +309,7 @@ describe("plapi", () => { ], }; - test("always sends include_secret_keys=true", async () => { + test("sends include_secret_keys=true by default", async () => { let requestedUrl = ""; stubFetch(async (input) => { requestedUrl = input.toString(); @@ -306,6 +322,19 @@ describe("plapi", () => { expect(url.searchParams.get("include_secret_keys")).toBe("true"); }); + test("omits include_secret_keys when the caller only needs public metadata", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApp), { status: 200 }); + }); + + await fetchApplication("app_abc", { includeSecretKeys: false }); + const url = new URL(requestedUrl); + expect(url.pathname).toBe("/v1/platform/applications/app_abc"); + expect(url.searchParams.has("include_secret_keys")).toBe(false); + }); + test("returns parsed application JSON", async () => { stubFetch(async () => new Response(JSON.stringify(mockApp), { status: 200 })); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index ba8f8054..7ce1afb2 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,13 +69,22 @@ export async function getAuthToken(): Promise { * throws PlapiError on non-ok responses. Debug logging is centralized in * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ -async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { +type PlapiFetchInit = { + body?: string; + idempotencyKey?: string; + /** Config version used for optimistic concurrency control. */ + ifMatch?: string; +}; + +async function plapiFetch(method: string, url: URL, init?: PlapiFetchInit): Promise { const token = await getAuthToken(); const headers: Record = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (init?.body) headers["Content-Type"] = "application/json"; + if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; + if (init?.ifMatch) headers["If-Match"] = init.ifMatch; const response = await loggedFetch(url, { tag: "plapi", method, @@ -229,9 +238,107 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -export async function fetchApplication(applicationId: string): Promise { +export type NativeSettings = { + object: "native_settings"; + api_enabled: boolean; +}; + +export type IOSApplication = { + object: "ios_application"; + id: string; + app_id_prefix: string; + bundle_id: string; + created_at: number; + updated_at: number; +}; + +export type CreateIOSApplicationParams = { + appIdPrefix: string; + bundleId: string; +}; + +export type IdempotentMutationOptions = { + /** Reuse this value when retrying the same mutation. */ + idempotencyKey: string; +}; + +export async function getNativeSettings( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function enableNativeApi( + applicationId: string, + envOrInstanceId: string, + options?: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("PATCH", url, { + body: JSON.stringify({ api_enabled: true }), + idempotencyKey: options?.idempotencyKey, + }); + return response.json() as Promise; +} + +export async function listIOSApplications( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createIOSApplication( + applicationId: string, + envOrInstanceId: string, + params: CreateIOSApplicationParams, + options: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url, { + body: JSON.stringify({ + app_id_prefix: params.appIdPrefix, + bundle_id: params.bundleId, + }), + idempotencyKey: options.idempotencyKey, + }); + return response.json() as Promise; +} + +export interface FetchApplicationOptions { + /** + * Include instance secret keys in the response. This defaults to true for + * backwards compatibility; callers that only need publishable metadata + * should opt out so secret keys never enter their process. + */ + includeSecretKeys?: boolean; +} + +export async function fetchApplication( + applicationId: string, + options: FetchApplicationOptions = {}, +): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - url.searchParams.set("include_secret_keys", "true"); + if (options.includeSecretKeys !== false) { + url.searchParams.set("include_secret_keys", "true"); + } const response = await plapiFetch("GET", url); return response.json() as Promise; } @@ -277,12 +384,18 @@ export async function triggerApplicationDomainDNSCheck( return response.json() as Promise; } +export type InstanceConfigMutationOptions = { + destructive?: boolean; + dryRun?: boolean; + ifMatch?: string; +}; + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ): Promise> { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, @@ -294,7 +407,10 @@ async function sendInstanceConfig( if (options?.dryRun) { url.searchParams.set("dry_run", "true"); } - const response = await plapiFetch(method, url, { body: JSON.stringify(config) }); + const response = await plapiFetch(method, url, { + body: JSON.stringify(config), + ifMatch: options?.ifMatch, + }); return response.json() as Promise>; } @@ -302,14 +418,14 @@ export const putInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PUT", applicationId, instanceId, config, options); export const patchInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise { diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index 4e9ee31c..1aa94c44 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -158,6 +158,11 @@ export function startCommandTelemetry(actionCommand: TelemetryCommand): void { } } +/** 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 50ff159d..8833227f 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 5db6e2d9..73deb542 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -29,6 +29,11 @@ export * as bootstrapMod from "../../commands/init/bootstrap.ts"; export * as nextStepsMod from "../../lib/next-steps.ts"; export * as keylessMod from "../../lib/keyless.ts"; export * as keylessTargetMod from "../../lib/keyless-target.ts"; +export * as iosApplyMod from "../../commands/init/ios/apply.ts"; +export * as nativeRemoteMod from "../../commands/init/ios/native-remote.ts"; +export * as nativeAppleMod from "../../commands/init/ios/native-apple.ts"; +export * as plapiMod from "../../lib/plapi.ts"; +export * as fapiMod from "../../lib/fapi.ts"; import * as loginModule from "../../commands/auth/login.ts"; import * as linkModule from "../../commands/link/index.ts"; @@ -45,6 +50,15 @@ import * as heuristicsModule from "../../commands/init/heuristics.ts"; import * as skillsModule from "../../commands/init/skills.ts"; import * as bootstrapModule from "../../commands/init/bootstrap.ts"; import * as keylessModule from "../../lib/keyless.ts"; +import * as iosApplyModule from "../../commands/init/ios/apply.ts"; +import * as nativeRemoteModule from "../../commands/init/ios/native-remote.ts"; +import * as nativeAppleModule from "../../commands/init/ios/native-apple.ts"; +import * as plapiModule from "../../lib/plapi.ts"; +import * as fapiModule from "../../lib/fapi.ts"; +import { + IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + type IOSNativeReadinessAudit, +} from "../../commands/init/ios/native-readiness.ts"; export const FAKE_CTX = { cwd: "/tmp/test", @@ -69,6 +83,36 @@ export const FAKE_BOOTSTRAP = { packageManager: "npm" as const, }; +export const FAKE_IOS_NATIVE_READINESS: IOSNativeReadinessAudit = { + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: "/tmp/test", + target: { + status: "selected", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + targetName: "MyApp", + bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: "LEGACY1234", + }, + }, + associatedDomain: { + status: "satisfied", + expectedDomain: "webcredentials:clerk.example.test", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [], + }, + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, +}; + type FakeFramework = { dep: string; name: string; @@ -158,8 +202,58 @@ export function useInitHarness(): InitHarness { spyOn(loginModule, "login").mockResolvedValue(undefined as never), spyOn(linkModule, "link").mockResolvedValue(undefined), spyOn(pullModule, "pull").mockResolvedValue(undefined), + spyOn(pullModule, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }), + spyOn(fapiModule, "fetchUserSettings").mockResolvedValue({ social: {} } as never), spyOn(bootstrapModule, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), spyOn(bootstrapModule, "confirmOverwrite").mockResolvedValue(undefined), + spyOn(iosApplyModule, "applyIOSLocalSetup").mockResolvedValue({ + targetName: "MyApp", + nativeReadiness: FAKE_IOS_NATIVE_READINESS, + prebuiltAuthRequested: false, + prebuiltAuthActive: false, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: false, + verifiesExistingKey: false, + }), + spyOn(iosApplyModule, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined), + spyOn(iosApplyModule, "applyIOSRuntimeKeySetup").mockResolvedValue(undefined), + spyOn(iosApplyModule, "verifyIOSRuntimeKeySetup").mockResolvedValue(undefined), + spyOn(nativeRemoteModule, "prepareIOSNativeRemoteSetup").mockResolvedValue({ + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: "satisfied", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + appIdPrefix: "LEGACY1234", + nativeApi: "satisfied", + registration: "satisfied", + actions: [], + blockers: [], + }), + spyOn(nativeRemoteModule, "applyIOSNativeRemoteSetup").mockResolvedValue(undefined), + spyOn(nativeAppleModule, "prepareIOSNativeAppleConnection").mockResolvedValue({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "satisfied", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + desired: { enabled: true, authenticatable: true }, + actions: [], + blockers: [], + }), + spyOn(nativeAppleModule, "applyIOSNativeAppleConnection").mockResolvedValue(undefined), + spyOn(plapiModule, "listApplications").mockResolvedValue([]), spyOn(keylessModule, "createAccountlessApp").mockResolvedValue({ publishable_key: "pk_test_stub", secret_key: "sk_test_stub", diff --git a/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj b/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj index 95eb6a49..b62d4047 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 00000000..f6a40a67 --- /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 00000000..f76746c6 --- /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 00000000..6ca35d65 --- /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 b7da2569..d1823f32 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 32f71f85..bc626dcf 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"); @@ -70,6 +74,7 @@ export async function linkProject(projectDir: string, configDir: string): Promis .env({ CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, + ...(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 0a286faf..6be0a8c3 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,48 +11,165 @@ 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: "", + }) + .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) .env({ CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, + ...(iosPlatformStub ? { CLERK_PLATFORM_API_URL: iosPlatformStub.url.origin } : {}), }) .quiet() .nothrow(); @@ -64,27 +182,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}`)); }