From c1c9b8b3a5475afe42ef063b4cce67cc7ec18b4e Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 15:07:01 -0400 Subject: [PATCH 01/16] fix(ios): scope established app setup gates to operation evidence --- docs/native-established-apps.md | 28 ++ .../cli-core/src/commands/doctor/ios.test.ts | 112 +++++- packages/cli-core/src/commands/doctor/ios.ts | 45 ++- .../src/commands/init/frameworks/ios.ts | 4 +- .../cli-core/src/commands/init/ios/apply.ts | 14 +- .../commands/init/ios/established-app.test.ts | 145 ++++++++ .../src/commands/init/ios/native-readiness.ts | 2 +- .../commands/init/ios/platform-views.test.ts | 32 ++ .../src/commands/init/ios/platform-views.ts | 46 ++- .../ClerkCorpusIOS.xcodeproj/project.pbxproj | 335 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 ++ .../Assets.xcassets/Contents.json | 6 + .../AuthenticationService.swift | 17 + .../ClerkCorpusIOS.entitlements | 12 + .../ClerkCorpusIOS/ClerkCorpusIOSApp.swift | 16 + .../ClerkCorpusIOS/ContentView.swift | 21 ++ test/fixtures/ios-established/README.md | 29 ++ 19 files changed, 885 insertions(+), 32 deletions(-) create mode 100644 docs/native-established-apps.md create mode 100644 packages/cli-core/src/commands/init/ios/established-app.test.ts create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.pbxproj create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/Contents.json create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/AuthenticationService.swift create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOS.entitlements create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOSApp.swift create mode 100644 test/fixtures/ios-established/ClerkCorpusIOS/ContentView.swift create mode 100644 test/fixtures/ios-established/README.md diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md new file mode 100644 index 000000000..225992538 --- /dev/null +++ b/docs/native-established-apps.md @@ -0,0 +1,28 @@ +# Established native Apple apps + +Support depends on the operation's evidence, not the app's age. Existing custom +runtime configuration remains developer-owned. Selecting a Clerk app with +`--app` authorizes setup against that app; it does not prove which app a custom +runtime publishable key belongs to. + +| Operation | Required evidence | Effect of uncertain custom startup wiring | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| Link SDK products | Exhaustive target discovery, consistent platform views, complete source membership for product selection, and a safe package/project edit | May proceed when those prerequisites hold; preserve Swift | +| Register the native app during init | Explicit Clerk app selection for custom configuration, one Bundle ID across configurations/platforms, verified App ID Prefix, and additive remote reconciliation | May proceed when the local preflight and identity checks pass; does not verify runtime configuration | +| Doctor registration check | Linked Clerk app/development instance and independently verified target/platform identity; registration audit must resolve or report prefix ambiguity | May run despite incomplete Swift membership; reports only Native API and registration state | +| Rewrite runtime configuration or insert prebuilt UI | Proven runtime/source ownership and the relevant source plan's existing checks | Remains blocked; no parser expansion or inferred startup execution | +| Configure associated domains | Proven domain/key inputs and the capability planner's ownership checks | An unproven custom startup call does not supply a domain; preserve existing entitlements and report manual follow-up | +| Diagnose key matching, AuthView, or Apple authentication | The relevant runtime, source, entitlement, and linked-app evidence | Doctor's registration-only fallback does not run these checks or imply they passed | + +`init` still rejects incomplete source discovery before edits: its SDK choice and +combined local plan depend on that evidence. Doctor is read-only and can retain a +source-discovery failure while reporting an independently supported registration +result. Unresolved containers, target/platform selection, conflicting identities, +and divergent platform Swift setup remain blockers. The registration-only proof +is not an approved mutation plan. + +The real-Xcode-derived [established app fixture](../test/fixtures/ios-established/README.md) +exercises partial integration and a rerun with existing package linkage and remote +registration. Its tests check both safe progress and preserved blockers. This is +a focused acceptance case, not a claim to understand arbitrary Swift startup code +or every mature Xcode project layout. diff --git a/packages/cli-core/src/commands/doctor/ios.test.ts b/packages/cli-core/src/commands/doctor/ios.test.ts index ab1593809..f5a519d59 100644 --- a/packages/cli-core/src/commands/doctor/ios.test.ts +++ b/packages/cli-core/src/commands/doctor/ios.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { convertIOSFixtureToMultiplatform, convertIOSFixtureToPlatformFilteredAppRoots, @@ -1935,3 +1935,111 @@ struct MyApp: App { expect(JSON.stringify(audit.results)).not.toContain("apple-secret-value"); }); }); + +describe("established apps with incomplete source discovery", () => { + async function establishedFixture(conflictingBundle = false) { + const root = await mkdtemp(join(tmpdir(), "clerk-established-doctor-")); + roots.push(root); + await cp(resolve(import.meta.dir, "../../../../../test/fixtures/ios-established"), root, { + recursive: true, + }); + const path = join(root, "ClerkCorpusIOS.xcodeproj", "project.pbxproj"); + let project = await readFile(path, "utf8"); + // A missing build-file record leaves additional Swift membership unknown, + // while the selected target's build settings and entitlements remain readable. + project = project.replace( + /(isa = PBXSourcesBuildPhase;[\s\S]*?files = \()/, + "$1 FEFEFEFEFEFEFEFEFEFEFEFE,", + ); + if (conflictingBundle) { + project = project.replace( + "PRODUCT_BUNDLE_IDENTIFIER = com.clerk.ClerkCorpusIOS;", + "PRODUCT_BUNDLE_IDENTIFIER = com.clerk.OtherApp;", + ); + } + await writeFile(path, project); + return root; + } + + test.each([true, false])( + "checks registration independently when registered=%s", + async (registered) => { + const root = await establishedFixture(); + let registrationReads = 0; + const unexpected = async (): Promise => { + throw new Error("Source-dependent inspection must remain blocked"); + }; + const { inspection, results } = await runIOSDoctorChecks( + context(), + { root }, + dependencies({ + fetchApplication: unexpected, + fetchUserSettings: unexpected, + planIOSSDKInstall: unexpected, + planMacOSNetworkCapability: unexpected, + auditIOSNativeAppleHealth: unexpected, + planIOSAppleEntitlement: unexpected, + listIOSApplications: async () => { + registrationReads += 1; + return registered + ? [ + { + object: "ios_application", + id: "iosapp_test", + app_id_prefix: "LEGACY1234", + bundle_id: "com.clerk.ClerkCorpusIOS", + created_at: 1, + updated_at: 1, + }, + ] + : []; + }, + }), + ); + expect(inspection.appTargets[0]!.swift.evidenceComplete).toBe(false); + expect(registrationReads).toBe(1); + expect(results).toContainEqual( + expect.objectContaining({ + name: "iOS: Native Application", + status: registered ? "pass" : "fail", + message: registered + ? "Native API and iOS registration: configured" + : "Native API or iOS registration: setup required", + }), + ); + expect( + results.some( + (result) => + result.status === "fail" && + `${result.message} ${result.detail}`.includes("could not be inspected completely"), + ), + ).toBe(true); + expect(results.some((result) => result.name.includes("Linked development key"))).toBe(false); + expect(results.some((result) => result.name.includes("Linked Clerk application"))).toBe( + false, + ); + }, + ); + + test("still refuses remote reads when incomplete sources accompany conflicting target identity", async () => { + const root = await establishedFixture(true); + let remoteReads = 0; + const { results } = await runIOSDoctorChecks( + context(), + { root }, + dependencies({ + getNativeSettings: async () => { + remoteReads += 1; + return { object: "native_settings", api_enabled: true }; + }, + listIOSApplications: async () => { + remoteReads += 1; + return []; + }, + }), + ); + expect(remoteReads).toBe(0); + expect(results.some((result) => result.name === "iOS: Native Application")).toBe(false); + expect(results.some((result) => result.status === "fail")).toBe(true); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/ios.ts b/packages/cli-core/src/commands/doctor/ios.ts index 8d876dc79..34981e88a 100644 --- a/packages/cli-core/src/commands/doctor/ios.ts +++ b/packages/cli-core/src/commands/doctor/ios.ts @@ -25,6 +25,7 @@ import { inspectIOSPlatformViews, iosPlatformViewsHaveAppleEntitlementIntent, iosPlatformViewsHaveNativeAppleIntent, + type IOSPlatformNativeIdentity, type IOSPlatformViewsSnapshot, } from "../init/ios/platform-views.ts"; import { hasSupportedIOSCustomConfigure } from "../init/ios/products.ts"; @@ -471,8 +472,12 @@ async function remoteResults( inspection: IOSProjectInspectionResult, dependencies: IOSDoctorDependencies, platformViews?: IOSPlatformViewsSnapshot, + registrationIdentity?: IOSPlatformNativeIdentity, ): Promise { - const readiness = buildIOSNativeReadinessAudit(inspection, { platformViews }); + const registrationOnly = registrationIdentity != null; + const readiness = buildIOSNativeReadinessAudit(inspection, { + platformViews: platformViews ?? registrationIdentity, + }); const target = selectedTarget(inspection); const platform = target?.platform ?? (inspection.platform === "macos" ? "macos" : "ios"); const nativeApplicationName = `${platformLabel(platform)}: Native Application`; @@ -499,7 +504,7 @@ async function remoteResults( const profile = await ctx.getProfile(); if (!profile) { - if (target) { + if (target && !registrationOnly) { const authView = await authViewEnvironmentResult(target, dependencies, { root: inspection.root, configureStatus: configureStep?.status, @@ -537,9 +542,9 @@ async function remoteResults( const instanceId = profile.profile.instances.development; try { const [application, remotePlan] = await Promise.all([ - dependencies.fetchApplication(applicationId, { - includeSecretKeys: false, - }), + registrationOnly + ? undefined + : dependencies.fetchApplication(applicationId, { includeSecretKeys: false }), auditIOSNativeRemoteSetup( { applicationId, instanceId, target: readiness.target }, { @@ -548,12 +553,13 @@ async function remoteResults( }, ), ]); - const customApplication = customSource - ? linkedCustomApplicationResult(application, instanceId, platform) - : undefined; + const customApplication = + customSource && application + ? linkedCustomApplicationResult(application, instanceId, platform) + : undefined; const linkedResult = customApplication?.result ?? - (configureStep?.status === "satisfied" + (application && configureStep?.status === "satisfied" ? linkedDevelopmentKeyResult(inspection, application, instanceId) : undefined); const localPublishableKey = inspection.localPublishableKey; @@ -562,7 +568,7 @@ async function remoteResults( (!customSource && linkedResult?.status === "pass" && localPublishableKey.state === "valid" ? localPublishableKey.frontendApiHost : undefined); - if (target) { + if (target && !registrationOnly) { const authView = await authViewEnvironmentResult(target, dependencies, { root: inspection.root, configureStatus: configureStep?.status, @@ -603,10 +609,16 @@ async function remoteResults( ? `Native API or ${platformLabel(platform)} registration: setup required` : `Native API or ${platformLabel(platform)} registration: blocked`, ...(detail ? { detail } : {}), - remedy: REMOTE_REMEDY, + remedy: registrationOnly + ? "Resolve the reported source-discovery issue, then run `clerk init --target `; native registration can also be completed in the Clerk Dashboard." + : REMOTE_REMEDY, }); } + // Source discovery is incomplete: registration does not establish runtime + // key matching, AuthView compatibility, or Sign in with Apple readiness. + if (registrationOnly) return results; + const bundleIdentifier = readiness.target.bundleIdentifier; const hasAppleEntitlement = platformViews ? iosPlatformViewsHaveAppleEntitlementIntent(platformViews) @@ -824,6 +836,17 @@ export async function runIOSDoctorChecks( platformCompatibilityBlockers, ); if (platformViewsAudit?.status === "blocked") { + if (platformViewsAudit.nativeIdentity) { + results.push( + ...(await remoteResults( + ctx, + inspection, + dependencies, + undefined, + platformViewsAudit.nativeIdentity, + )), + ); + } return { inspection, results }; } if (target && !target.platformEvidenceComplete) { diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 4162dab06..7ac24276c 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -47,7 +47,7 @@ export const ios: FrameworkScaffold = { const sdkLabel = platform === "ios" ? "Clerk iOS SDK" : "Clerk Swift SDK"; const productDecision = proposal.productDecision ?? "prebuilt"; const includeClerkKitUI = productDecision === "prebuilt"; - const hasCustomConfigure = proposal.hasSupportedCustomConfigure; + const hasCustomConfigure = proposal.hasCustomConfigure; const setupPlan = proposal.setupPlan; const platformCompatibilityBlockers = proposal.platformCompatibilityBlockers; if (platformCompatibilityBlockers.length > 0) { @@ -122,7 +122,7 @@ export const ios: FrameworkScaffold = { ? [configureStep.description] : hasCustomConfigure ? [ - "Keep the existing custom Clerk.configure(...) source unchanged. Select the Clerk application it belongs to during setup, or pass --app in agent mode; clerk init does not inspect or rewrite the custom key value.", + "Keep the existing custom Clerk.configure(...) source unchanged. Verify that it runs at startup and that its runtime key belongs to the selected Clerk application; --app selects the application for setup but does not prove a runtime key match. clerk init does not inspect or rewrite the custom key value.", ] : [ 'Configure Clerk directly in the single shipping `@main` App initializer with the selected application\'s development publishable key: `Clerk.configure(publishableKey: "")`. For a safely inspectable SwiftUI target, `clerk init` applies this with the value redacted from previews and output.', diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 7cf53ef5f..80a3e10dd 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -455,11 +455,6 @@ export async function applyIOSLocalSetup( "The fresh SwiftUI target was not edited because the selected runtime configuration could not be proven. Configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.", ); } - if (hasCustomConfigure && !hasSupportedCustomConfigure) { - throw iosSetupError( - "A custom Clerk.configure(...) source was found, but it is not one unambiguous call in the selected app's startup initializer. clerk init preserved it and made no local or remote changes. Confirm the shipping configuration manually, then rerun the command.", - ); - } if (prebuiltAuthActive) { if (prebuiltRuntimeBlockers.length > 0) { throw iosSetupError( @@ -627,12 +622,17 @@ export async function applyIOSLocalSetup( ), ); } - if (hasSupportedCustomConfigure) { + if (hasCustomConfigure) { log.info( dim( " PRESERVE Custom Clerk.configure(...) publishable-key source. Its value will not be inspected; the developer must select the existing Clerk application it belongs to.", ), ); + if (!hasSupportedCustomConfigure) { + log.warn( + "Custom Clerk configuration: startup execution and runtime key match remain unverified. SDK linkage and native registration can proceed for the explicitly selected Clerk app; verify runtime initialization and the custom key's application manually.", + ); + } } if (prebuiltAuthPlan) { const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; @@ -780,7 +780,7 @@ export async function applyIOSLocalSetup( requiresDevelopmentKey: directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, requiresExplicitApplication: - hasSupportedCustomConfigure || directConfigPlan?.changes?.configuration === "verify-existing", + hasCustomConfigure || directConfigPlan?.changes?.configuration === "verify-existing", }; } diff --git a/packages/cli-core/src/commands/init/ios/established-app.test.ts b/packages/cli-core/src/commands/init/ios/established-app.test.ts new file mode 100644 index 000000000..671ead6f5 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/established-app.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { cp, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import { treeDigest } from "./test-helpers.ts"; +import { + cleanupApplyCLITestState, + createIsolatedCLIState, + currentNativeRemoteState, + resetApplyCLITestRemoteState, + runCLI, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; + +setDefaultTimeout(15_000); +beforeEach(resetApplyCLITestRemoteState); +afterEach(cleanupApplyCLITestState); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "clerk-established-app-")); + temporaryDirectories.push(root); + await cp(resolve(import.meta.dir, "../../../../../../test/fixtures/ios-established"), root, { + recursive: true, + }); + return root; +} + +const initArgs = ["--mode", "agent", "init", "--yes", "--target", "ClerkCorpusIOS"]; + +test("links the SDK and registers an established app while preserving delegated runtime configuration", async () => { + const root = await fixture(); + const configDir = await createIsolatedCLIState(); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + expect(target.swift.evidenceComplete).toBe(true); + expect(target.swift.configureCalls).toHaveLength(1); + expect(target.swift.configureCalls[0]!.publishableKeyWiring).toBe("custom"); + expect(target.swift.configureCalls[0]!.startupBinding).not.toBe("app-init"); + const sourceBefore = await treeDigest(join(root, "ClerkCorpusIOS")); + const args = [...initArgs, "--app", "app_ios_apply"]; + + const first = await runCLI(root, args, configDir); + expect(first.exitCode, `${first.stdout}\n${first.stderr}`).toBe(0); + expect(`${first.stdout}\n${first.stderr}`).toContain( + "startup execution and runtime key match remain unverified", + ); + expect(`${first.stdout}\n${first.stderr}`).not.toContain("Clerk is already set up"); + expect(`${first.stdout}\n${first.stderr}`).toContain("does not prove a runtime key match"); + expect(`${first.stdout}\n${first.stderr}`).not.toContain("Configure Clerk directly"); + expect(await treeDigest(join(root, "ClerkCorpusIOS"))).toEqual(sourceBefore); + const applied = await inspectIOSProject(root); + expect(applied.appTargets[0]!.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(currentNativeRemoteState()).toMatchObject({ + nativeAPIEnabled: true, + iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.clerk.ClerkCorpusIOS" }], + mutations: { + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 0, + }, + }); + + const beforeRerun = await treeDigest(root); + const remoteBefore = currentNativeRemoteState(); + const rerun = await runCLI(root, args, configDir); + expect(rerun.exitCode, `${rerun.stdout}\n${rerun.stderr}`).toBe(0); + expect(`${rerun.stdout}\n${rerun.stderr}`).toContain( + "startup execution and runtime key match remain unverified", + ); + expect(`${rerun.stdout}\n${rerun.stderr}`).not.toContain("Clerk is already set up"); + expect(await treeDigest(root)).toEqual(beforeRerun); + expect(currentNativeRemoteState()).toEqual(remoteBefore); +}); + +test("requires an explicit Clerk app before making progress with a delegated runtime key", async () => { + const root = await fixture(); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + const result = await runCLI(root, initArgs, configDir); + expect(result.exitCode).toBe(2); + expect(`${result.stdout}\n${result.stderr}`).toContain("--app"); + expect(await treeDigest(root)).toEqual(before); + expect(currentNativeRemoteState().mutations).toEqual({ + nativeSettingsPatchCount: 0, + iosApplicationPostCount: 0, + appleConfigPatchCount: 0, + }); +}); + +test.each(["incomplete sources", "conflicting Bundle IDs", "missing prefix", "prebuilt UI"])( + "preserves operation-specific blockers: %s", + async (blocker) => { + const root = await fixture(); + const configDir = await createIsolatedCLIState(); + const path = join(root, "ClerkCorpusIOS.xcodeproj", "project.pbxproj"); + if (blocker === "incomplete sources") { + await Bun.write( + path, + (await Bun.file(path).text()).replace( + /(isa = PBXSourcesBuildPhase;[\s\S]*?files = \()/, + "$1 FEFEFEFEFEFEFEFEFEFEFEFE,", + ), + ); + } else if (blocker === "conflicting Bundle IDs") { + await Bun.write( + path, + (await Bun.file(path).text()).replace( + "PRODUCT_BUNDLE_IDENTIFIER = com.clerk.ClerkCorpusIOS;", + "PRODUCT_BUNDLE_IDENTIFIER = com.clerk.OtherApp;", + ), + ); + } else if (blocker === "missing prefix") { + const entitlements = join(root, "ClerkCorpusIOS", "ClerkCorpusIOS.entitlements"); + await Bun.write( + entitlements, + (await Bun.file(entitlements).text()).replace( + /application-identifier<\/key>\s*[^<]*<\/string>/, + "", + ), + ); + } + const before = await treeDigest(root); + const result = await runCLI( + root, + [ + ...initArgs, + "--app", + "app_ios_apply", + ...(blocker === "prebuilt UI" ? ["--prebuilt-auth-ui"] : []), + ], + configDir, + ); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).not.toBe(0); + expect(await treeDigest(root)).toEqual(before); + expect(currentNativeRemoteState().mutations).toEqual({ + nativeSettingsPatchCount: 0, + iosApplicationPostCount: 0, + appleConfigPatchCount: 0, + }); + }, +); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts index 5e3ba0e93..b59475017 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -116,7 +116,7 @@ export interface IOSNativeReadinessAudit { export interface BuildIOSNativeReadinessAuditOptions { associatedDomainPlan?: IOSAssociatedDomainPlan; /** Exhaustive cross-platform identity evidence for a multiplatform target. */ - platformViews?: IOSPlatformViewsSnapshot; + platformViews?: Pick; } function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { diff --git a/packages/cli-core/src/commands/init/ios/platform-views.test.ts b/packages/cli-core/src/commands/init/ios/platform-views.test.ts index 50156b044..d3fbe8f8c 100644 --- a/packages/cli-core/src/commands/init/ios/platform-views.test.ts +++ b/packages/cli-core/src/commands/init/ios/platform-views.test.ts @@ -346,3 +346,35 @@ struct MacCallbackView: View { ).toBeUndefined(); }); }); + +test.each([false, true])( + "preserves independent identity only after inspecting every platform (failed macOS=%s)", + async (failedMacOS) => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const result = await audit(root, async (input, options) => { + if (failedMacOS && options?.platform === "macos") throw new Error("unreadable macOS view"); + const inspection = await inspectIOSProject(input, options); + for (const target of inspection.appTargets) target.swift.evidenceComplete = false; + return inspection; + }); + expect(result.status).toBe("blocked"); + if (result.status !== "blocked") + throw new Error("expected incomplete Swift evidence to block edits"); + if (failedMacOS) { + expect(blockerCodes(result)).toContain("platform-inspection-failed"); + expect(result.nativeIdentity).toBeUndefined(); + } else { + expect(blockerCodes(result)).toEqual([ + "incomplete-swift-evidence", + "incomplete-swift-evidence", + ]); + expect(result.nativeIdentity).toMatchObject({ + targetId: IOS_FIXTURE_IDS.appTarget, + bundleIdentifier: "com.example.myapp", + supportedPlatforms: ["ios", "macos"], + }); + expect(result).not.toHaveProperty("snapshot"); + } + }, +); diff --git a/packages/cli-core/src/commands/init/ios/platform-views.ts b/packages/cli-core/src/commands/init/ios/platform-views.ts index ddfeb82c4..c80e186bc 100644 --- a/packages/cli-core/src/commands/init/ios/platform-views.ts +++ b/packages/cli-core/src/commands/init/ios/platform-views.ts @@ -106,9 +106,20 @@ export interface IOSPlatformViewsSnapshot { platforms: IOSPlatformTargetViewSnapshot[]; } +/** Identity proven across every platform, independently of Swift source discovery. */ +export type IOSPlatformNativeIdentity = Pick< + IOSPlatformViewsSnapshot, + "projectPath" | "targetId" | "supportedPlatforms" | "bundleIdentifier" | "appIdPrefix" +>; + export type IOSPlatformViewsAudit = | { status: "ready"; snapshot: IOSPlatformViewsSnapshot } - | { status: "blocked"; blockers: IOSPlatformViewBlocker[] }; + | { + status: "blocked"; + blockers: IOSPlatformViewBlocker[]; + /** Read-only registration checks may proceed; this is not approval for local or remote writes. */ + nativeIdentity?: IOSPlatformNativeIdentity; + }; function canonicalPlatforms(platforms: readonly IOSNativePlatform[]): IOSNativePlatform[] { const values = new Set(platforms); @@ -428,14 +439,6 @@ export async function inspectIOSPlatformViews( }); continue; } - if (!target.swift.evidenceComplete) { - blockers.push({ - code: "incomplete-swift-evidence", - platform: view.platform, - message: `Clerk-relevant Swift source membership could not be inspected completely for the ${view.platform === "macos" ? "macOS" : "iOS"} target view.`, - }); - continue; - } targets.push({ platform: view.platform, target }); } if (blockers.length > 0) return { status: "blocked", blockers }; @@ -481,6 +484,31 @@ export async function inspectIOSPlatformViews( } if (blockers.length > 0) return { status: "blocked", blockers }; + // Incomplete Swift membership blocks source-dependent plans, but does not + // invalidate identity that all platform-conditioned build settings proved. + for (const { platform, target } of targets) { + if (!target.swift.evidenceComplete) { + blockers.push({ + code: "incomplete-swift-evidence", + platform, + message: `Clerk-relevant Swift source membership could not be inspected completely for the ${platform === "macos" ? "macOS" : "iOS"} target view.`, + }); + } + } + if (blockers.length > 0) { + return { + status: "blocked", + blockers, + nativeIdentity: { + projectPath: primaryTarget.projectPath, + targetId: primaryTarget.id, + supportedPlatforms, + bundleIdentifier: resolvedBundleIdentities[0]!, + ...(prefixes[0] ? { appIdPrefix: prefixes[0] } : {}), + }, + }; + } + const platformSnapshots = targets .map(({ platform, target }): IOSPlatformTargetViewSnapshot => ({ platform, diff --git a/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.pbxproj b/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.pbxproj new file mode 100644 index 000000000..a5aee08ab --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.pbxproj @@ -0,0 +1,335 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 918730283045BE25004E5BD0 /* ClerkCorpusIOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ClerkCorpusIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 9187302A3045BE25004E5BD0 /* ClerkCorpusIOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ClerkCorpusIOS; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 918730253045BE25004E5BD0 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9187301F3045BE25004E5BD0 = { + isa = PBXGroup; + children = ( + 9187302A3045BE25004E5BD0 /* ClerkCorpusIOS */, + 918730293045BE25004E5BD0 /* Products */, + ); + sourceTree = ""; + }; + 918730293045BE25004E5BD0 /* Products */ = { + isa = PBXGroup; + children = ( + 918730283045BE25004E5BD0 /* ClerkCorpusIOS.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 918730273045BE25004E5BD0 /* ClerkCorpusIOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 918730333045BE26004E5BD0 /* Build configuration list for PBXNativeTarget "ClerkCorpusIOS" */; + buildPhases = ( + 918730243045BE25004E5BD0 /* Sources */, + 918730253045BE25004E5BD0 /* Frameworks */, + 918730263045BE25004E5BD0 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 9187302A3045BE25004E5BD0 /* ClerkCorpusIOS */, + ); + name = ClerkCorpusIOS; + packageProductDependencies = ( + ); + productName = ClerkCorpusIOS; + productReference = 918730283045BE25004E5BD0 /* ClerkCorpusIOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 918730203045BE25004E5BD0 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2650; + LastUpgradeCheck = 2650; + TargetAttributes = { + 918730273045BE25004E5BD0 = { + CreatedOnToolsVersion = 26.5; + }; + }; + }; + buildConfigurationList = 918730233045BE25004E5BD0 /* Build configuration list for PBXProject "ClerkCorpusIOS" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 9187301F3045BE25004E5BD0; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 918730293045BE25004E5BD0 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 918730273045BE25004E5BD0 /* ClerkCorpusIOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 918730263045BE25004E5BD0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 918730243045BE25004E5BD0 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 918730313045BE26004E5BD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 918730323045BE26004E5BD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 918730343045BE26004E5BD0 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = ClerkCorpusIOS/ClerkCorpusIOS.entitlements; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.ClerkCorpusIOS; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 918730353045BE26004E5BD0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CODE_SIGN_ENTITLEMENTS = ClerkCorpusIOS/ClerkCorpusIOS.entitlements; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.clerk.ClerkCorpusIOS; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 918730233045BE25004E5BD0 /* Build configuration list for PBXProject "ClerkCorpusIOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 918730313045BE26004E5BD0 /* Debug */, + 918730323045BE26004E5BD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 918730333045BE26004E5BD0 /* Build configuration list for PBXNativeTarget "ClerkCorpusIOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 918730343045BE26004E5BD0 /* Debug */, + 918730353045BE26004E5BD0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 918730203045BE25004E5BD0 /* Project object */; +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AccentColor.colorset/Contents.json b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..0afb3cf0e --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors": [ + { + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..c70a5bff1 --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images": [ + { + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "tinted" + } + ], + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/Contents.json b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/Contents.json new file mode 100644 index 000000000..74d6a722c --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/AuthenticationService.swift b/test/fixtures/ios-established/ClerkCorpusIOS/AuthenticationService.swift new file mode 100644 index 000000000..59416ea8c --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/AuthenticationService.swift @@ -0,0 +1,17 @@ +import ClerkKit +import Foundation + +enum AuthenticationService { + static func start() { + Clerk.configure(publishableKey: AppConfiguration.publishableKey) + } +} + +enum AppConfiguration { + static var publishableKey: String { + guard let key = Bundle.main.object(forInfoDictionaryKey: "ClerkPublishableKey") as? String else { + fatalError("Provide ClerkPublishableKey through the app's build configuration.") + } + return key + } +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOS.entitlements b/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOS.entitlements new file mode 100644 index 000000000..62f88f557 --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOS.entitlements @@ -0,0 +1,12 @@ + + + + + application-identifier + LEGACY1234.com.clerk.ClerkCorpusIOS + com.apple.developer.associated-domains + + webcredentials:existing.example.test + + + diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOSApp.swift b/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOSApp.swift new file mode 100644 index 000000000..95bacd122 --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/ClerkCorpusIOSApp.swift @@ -0,0 +1,16 @@ +import ClerkKit +import SwiftUI + +@main +struct ClerkCorpusIOSApp: App { + init() { + AuthenticationService.start() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } +} diff --git a/test/fixtures/ios-established/ClerkCorpusIOS/ContentView.swift b/test/fixtures/ios-established/ClerkCorpusIOS/ContentView.swift new file mode 100644 index 000000000..d2a8ea511 --- /dev/null +++ b/test/fixtures/ios-established/ClerkCorpusIOS/ContentView.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + NavigationStack { + List { + Section("Workspace") { + NavigationLink("Projects") { + Text("Your projects") + .navigationTitle("Projects") + } + NavigationLink("Account settings") { + Text("Manage your account") + .navigationTitle("Account settings") + } + } + } + .navigationTitle("Workspace") + } + } +} diff --git a/test/fixtures/ios-established/README.md b/test/fixtures/ios-established/README.md new file mode 100644 index 000000000..d277f3142 --- /dev/null +++ b/test/fixtures/ios-established/README.md @@ -0,0 +1,29 @@ +# Established Swift app + +This fixture derives from the real Xcode 26.5 iOS App template in the native +project corpus (generated September 21, 2026). Its original project document, +synchronized source group, build configurations, workspace, and asset catalogs +are retained. The changes add an existing navigation hierarchy, a delegated +authentication service, a custom Info.plist key source, and entitlements with a +literal App ID Prefix and an existing associated domain. + +The app calls `AuthenticationService.start()` from its initializer. That service +calls `Clerk.configure`, which is intentionally outside the CLI's proven startup +patterns. The CLI must preserve it without executing the service or reading its +custom key value. There is no credential in this fixture. Building is supported +after SDK linkage; launching requires the developer's `ClerkPublishableKey` +Info.plist value and a working authentication flow. + +`established-app.test.ts` starts with partial package integration, runs the actual +CLI against an isolated HTTP stub, and reruns against the resulting installed SDK +and registered native application. It checks unchanged Swift and entitlements, +no duplicate remote mutations, and explicit remaining runtime verification. +Negative cases cover incomplete source membership, conflicting Bundle IDs, +missing App ID Prefix, missing explicit application selection, and unsafe prebuilt +UI insertion. Doctor tests add an unresolved source build-file record while +keeping independently readable target identity. + +The HTTP stub proves command behavior, not live authentication. A separate local +validation built the CLI-modified fixture with Xcode 26.5 for a generic iOS +Simulator destination, with code signing disabled. No package pins, derived data, +or developer-specific Xcode files belong in this fixture. From 54c4279e7f0e94f7ffcc37078941ad40ec735636 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 15:24:56 -0400 Subject: [PATCH 02/16] fix(ios): preserve independent setup for existing AuthView --- docs/native-established-apps.md | 5 + .../commands/init/ios/established-app.test.ts | 134 ++++++++++++------ .../src/commands/init/ios/local-plan.ts | 18 ++- test/fixtures/ios-established/README.md | 4 + 4 files changed, 114 insertions(+), 47 deletions(-) diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md index 225992538..e3b0893c6 100644 --- a/docs/native-established-apps.md +++ b/docs/native-established-apps.md @@ -5,6 +5,11 @@ runtime configuration remains developer-owned. Selecting a Clerk app with `--app` authorizes setup against that app; it does not prove which app a custom runtime publishable key belongs to. +Existing `AuthView` is evidence for SDK product and version requirements. When its +runtime wiring is unresolved, it does not activate automatic AuthView setup or +provider-capability changes. SDK linkage and native registration may still +proceed. Explicit `--prebuilt-auth-ui` requests retain the runtime checks. + | Operation | Required evidence | Effect of uncertain custom startup wiring | | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | Link SDK products | Exhaustive target discovery, consistent platform views, complete source membership for product selection, and a safe package/project edit | May proceed when those prerequisites hold; preserve Swift | diff --git a/packages/cli-core/src/commands/init/ios/established-app.test.ts b/packages/cli-core/src/commands/init/ios/established-app.test.ts index 671ead6f5..748780407 100644 --- a/packages/cli-core/src/commands/init/ios/established-app.test.ts +++ b/packages/cli-core/src/commands/init/ios/established-app.test.ts @@ -3,9 +3,12 @@ import { cp, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { inspectIOSProject } from "./inspect.ts"; +import { applyIOSPrebuiltAuth, planIOSPrebuiltAuth } from "./prebuilt-auth.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.ts"; import { treeDigest } from "./test-helpers.ts"; import { cleanupApplyCLITestState, + canonicalSwiftUIFixture, createIsolatedCLIState, currentNativeRemoteState, resetApplyCLITestRemoteState, @@ -17,62 +20,109 @@ setDefaultTimeout(15_000); beforeEach(resetApplyCLITestRemoteState); afterEach(cleanupApplyCLITestState); -async function fixture() { +async function fixture(existingAuthView = false) { const root = await mkdtemp(join(tmpdir(), "clerk-established-app-")); temporaryDirectories.push(root); await cp(resolve(import.meta.dir, "../../../../../../test/fixtures/ios-established"), root, { recursive: true, }); + if (existingAuthView) { + await cp( + join(canonicalSwiftUIFixture, "MyApp", "ContentView.swift"), + join(root, "ClerkCorpusIOS", "ContentView.swift"), + ); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + const plan = await planIOSPrebuiltAuth({ + root, + projectPath: target.projectPath, + targetId: target.id, + allowDirty: true, + }); + expect(plan.status).toBe("ready"); + expect((await applyIOSPrebuiltAuth(plan)).status).toBe("applied"); + } return root; } const initArgs = ["--mode", "agent", "init", "--yes", "--target", "ClerkCorpusIOS"]; -test("links the SDK and registers an established app while preserving delegated runtime configuration", async () => { - const root = await fixture(); - const configDir = await createIsolatedCLIState(); - const inspection = await inspectIOSProject(root); - const target = inspection.appTargets[0]!; - expect(target.swift.evidenceComplete).toBe(true); - expect(target.swift.configureCalls).toHaveLength(1); - expect(target.swift.configureCalls[0]!.publishableKeyWiring).toBe("custom"); - expect(target.swift.configureCalls[0]!.startupBinding).not.toBe("app-init"); - const sourceBefore = await treeDigest(join(root, "ClerkCorpusIOS")); - const args = [...initArgs, "--app", "app_ios_apply"]; +test.each([false, true])( + "links the SDK and registers delegated runtime configuration (existing AuthView=%s)", + async (existingAuthView) => { + const root = await fixture(existingAuthView); + const configDir = await createIsolatedCLIState(); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + expect(target.swift.evidenceComplete).toBe(true); + expect(target.swift.configureCalls).toHaveLength(1); + expect(target.swift.configureCalls[0]!.publishableKeyWiring).toBe("custom"); + expect(target.swift.configureCalls[0]!.startupBinding).not.toBe("app-init"); + if (existingAuthView) { + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + }); + expect(proposal.inspectedPrebuiltAuthPlan?.status).toBe("satisfied"); + expect(proposal.installPlan?.requirePrebuiltAuthCompatibility).toBe(true); + expect(proposal.prebuiltRuntimeBlockers.length).toBeGreaterThan(0); + } + const sourceBefore = await treeDigest(join(root, "ClerkCorpusIOS")); + const args = [...initArgs, "--app", "app_ios_apply"]; - const first = await runCLI(root, args, configDir); - expect(first.exitCode, `${first.stdout}\n${first.stderr}`).toBe(0); - expect(`${first.stdout}\n${first.stderr}`).toContain( - "startup execution and runtime key match remain unverified", - ); - expect(`${first.stdout}\n${first.stderr}`).not.toContain("Clerk is already set up"); - expect(`${first.stdout}\n${first.stderr}`).toContain("does not prove a runtime key match"); - expect(`${first.stdout}\n${first.stderr}`).not.toContain("Configure Clerk directly"); - expect(await treeDigest(join(root, "ClerkCorpusIOS"))).toEqual(sourceBefore); - const applied = await inspectIOSProject(root); - expect(applied.appTargets[0]!.packages).toMatchObject({ - clerkKit: "linked", - clerkKitUI: "absent", - }); - expect(currentNativeRemoteState()).toMatchObject({ - nativeAPIEnabled: true, - iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.clerk.ClerkCorpusIOS" }], - mutations: { - nativeSettingsPatchCount: 1, - iosApplicationPostCount: 1, - appleConfigPatchCount: 0, - }, - }); + const first = await runCLI(root, args, configDir); + expect(first.exitCode, `${first.stdout}\n${first.stderr}`).toBe(0); + expect(`${first.stdout}\n${first.stderr}`).toContain( + "startup execution and runtime key match remain unverified", + ); + expect(`${first.stdout}\n${first.stderr}`).not.toContain("Clerk is already set up"); + expect(`${first.stdout}\n${first.stderr}`).toContain("does not prove a runtime key match"); + expect(`${first.stdout}\n${first.stderr}`).not.toContain("Configure Clerk directly"); + expect(`${first.stdout}\n${first.stderr}`).not.toContain( + "Inspecting AuthView authentication methods", + ); + expect(await treeDigest(join(root, "ClerkCorpusIOS"))).toEqual(sourceBefore); + const applied = await inspectIOSProject(root); + expect(applied.appTargets[0]!.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: existingAuthView ? "linked" : "absent", + }); + expect(currentNativeRemoteState()).toMatchObject({ + nativeAPIEnabled: true, + iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.clerk.ClerkCorpusIOS" }], + mutations: { + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 0, + }, + }); - const beforeRerun = await treeDigest(root); + const beforeRerun = await treeDigest(root); + const remoteBefore = currentNativeRemoteState(); + const rerun = await runCLI(root, args, configDir); + expect(rerun.exitCode, `${rerun.stdout}\n${rerun.stderr}`).toBe(0); + expect(`${rerun.stdout}\n${rerun.stderr}`).toContain( + "startup execution and runtime key match remain unverified", + ); + expect(`${rerun.stdout}\n${rerun.stderr}`).not.toContain("Clerk is already set up"); + expect(await treeDigest(root)).toEqual(beforeRerun); + expect(currentNativeRemoteState()).toEqual(remoteBefore); + }, +); + +test("still requires proven runtime wiring when existing AuthView setup is explicitly requested", async () => { + const root = await fixture(true); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); const remoteBefore = currentNativeRemoteState(); - const rerun = await runCLI(root, args, configDir); - expect(rerun.exitCode, `${rerun.stdout}\n${rerun.stderr}`).toBe(0); - expect(`${rerun.stdout}\n${rerun.stderr}`).toContain( - "startup execution and runtime key match remain unverified", + const result = await runCLI( + root, + [...initArgs, "--app", "app_ios_apply", "--prebuilt-auth-ui"], + configDir, ); - expect(`${rerun.stdout}\n${rerun.stderr}`).not.toContain("Clerk is already set up"); - expect(await treeDigest(root)).toEqual(beforeRerun); + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("requires a proven Clerk runtime"); + expect(await treeDigest(root)).toEqual(before); expect(currentNativeRemoteState()).toEqual(remoteBefore); }); diff --git a/packages/cli-core/src/commands/init/ios/local-plan.ts b/packages/cli-core/src/commands/init/ios/local-plan.ts index d9a44e16f..ffb67adb7 100644 --- a/packages/cli-core/src/commands/init/ios/local-plan.ts +++ b/packages/cli-core/src/commands/init/ios/local-plan.ts @@ -70,6 +70,7 @@ export interface IOSLocalSetupProposal { prebuiltAuthPlan?: IOSPrebuiltAuthPlan; prebuiltRuntimeBlockers: string[]; prebuiltAuthRequested: boolean; + /** Automatic AuthView setup; existing UI with unresolved runtime stays review-only. */ prebuiltAuthActive: boolean; installPlan?: IOSSDKInstallPlan; sdkInstallPlan?: IOSSDKInstallPlan; @@ -238,11 +239,11 @@ export async function buildIOSLocalSetupProposal( plan: inspectedPrebuiltAuthPlan, }); } - const prebuiltAuthActive = + const prebuiltAuthSelectedOrPresent = inspectedPrebuiltAuthPlan.status !== "blocked" && (prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"); - const includeClerkKitUI = platformViews.requiresClerkKitUI || prebuiltAuthActive; + const includeClerkKitUI = platformViews.requiresClerkKitUI || prebuiltAuthSelectedOrPresent; const installPlan = await planIOSSDKInstall({ root: options.root, projectPath: selection.projectPath, @@ -251,7 +252,7 @@ export async function buildIOSLocalSetupProposal( supportedPlatforms: selectedTarget.supportedPlatforms, includeClerkKitUI, requirePrebuiltAuthCompatibility: - platformViews.requiresAuthViewCompatibility || prebuiltAuthActive, + platformViews.requiresAuthViewCompatibility || prebuiltAuthSelectedOrPresent, }); const hasCustomConfigure = selectedTarget.swift.configureCalls.some( @@ -261,7 +262,7 @@ export async function buildIOSLocalSetupProposal( const directConfigPlan = shouldPlanIOSDirectConfig( inspection, selectedTarget, - prebuiltAuthActive ? "prebuilt" : productDecision, + prebuiltAuthSelectedOrPresent ? "prebuilt" : productDecision, ) ? await planIOSDirectConfig({ root: options.root, @@ -272,9 +273,16 @@ export async function buildIOSLocalSetupProposal( }) : undefined; - const prebuiltRuntimeBlockers = prebuiltAuthActive + const prebuiltRuntimeBlockers = prebuiltAuthSelectedOrPresent ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) : []; + // Observing existing UI does not opt into AuthView-specific work when its + // runtime cannot be proven. Keep that diagnostic without blocking independent + // SDK/registration work or deriving provider capabilities from an unknown key. + // Explicitly requested AuthView setup still fails its runtime prerequisites. + const prebuiltAuthActive = + prebuiltAuthSelectedOrPresent && + (prebuiltAuthRequested || prebuiltRuntimeBlockers.length === 0); const prebuiltAuthPlanForSetup = prebuiltRuntimeBlockers.length > 0 ? { diff --git a/test/fixtures/ios-established/README.md b/test/fixtures/ios-established/README.md index d277f3142..c8a407082 100644 --- a/test/fixtures/ios-established/README.md +++ b/test/fixtures/ios-established/README.md @@ -18,6 +18,10 @@ Info.plist value and a working authentication flow. CLI against an isolated HTTP stub, and reruns against the resulting installed SDK and registered native application. It checks unchanged Swift and entitlements, no duplicate remote mutations, and explicit remaining runtime verification. +It also repeats that lifecycle with the CLI-generated AuthView already present, +checking that both SDK products can be linked without rewriting existing UI or +implicitly activating AuthView provider setup. Explicit AuthView setup with +unproven runtime wiring must still fail before any mutation. Negative cases cover incomplete source membership, conflicting Bundle IDs, missing App ID Prefix, missing explicit application selection, and unsafe prebuilt UI insertion. Doctor tests add an unresolved source build-file record while From f252fa8f427e8ee77a2f021e67a1669e69c2ac6b Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 15:49:35 -0400 Subject: [PATCH 03/16] fix(ios): separate entitlement ownership from domain validation --- .../src/commands/init/frameworks/ios.test.ts | 8 +- .../commands/init/ios/apple-entitlement.ts | 11 +- .../commands/init/ios/associated-domain.ts | 758 +++--------------- .../init/ios/entitlements-files.test.ts | 156 ++++ .../commands/init/ios/entitlements-files.ts | 656 +++++++++++++++ .../src/commands/init/ios/macos-network.ts | 5 +- 6 files changed, 923 insertions(+), 671 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/entitlements-files.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/entitlements-files.ts diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 99b5c5568..c772c0d1a 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -6,6 +6,7 @@ import { ios } from "./ios.ts"; import type { ProjectContext } from "./types.ts"; import { createIOSFixture } from "../ios/test-helpers.ts"; import * as associatedDomain from "../ios/associated-domain.ts"; +import * as entitlementsFiles from "../ios/entitlements-files.ts"; import * as directConfig from "../ios/direct-config.ts"; const temporaryRoots: string[] = []; @@ -123,6 +124,7 @@ test("uses the selected macOS platform for final planning and guidance", async ( await createIOSFixture(root, { complete: false, clerkSDK: false, platform: "macos" }); const directPlanner = spyOn(directConfig, "planIOSDirectConfig"); const domainPlanner = spyOn(associatedDomain, "planIOSAssociatedDomain"); + const fileSelector = spyOn(entitlementsFiles, "selectIOSEntitlementsFiles"); try { const plan = await ios.scaffold({ @@ -135,9 +137,8 @@ test("uses the selected macOS platform for final planning and guidance", async ( expect(directPlanner).toHaveBeenCalledWith( expect.objectContaining({ root, platform: "macos" }), ); - expect(domainPlanner).toHaveBeenCalledWith( - expect.objectContaining({ root, platform: "macos" }), - ); + expect(domainPlanner).not.toHaveBeenCalled(); + expect(fileSelector).toHaveBeenCalledWith(expect.objectContaining({ root, platform: "macos" })); const instructions = plan.postInstructions.join("\n"); expect(instructions).toContain("Clerk Swift SDK"); @@ -150,6 +151,7 @@ test("uses the selected macOS platform for final planning and guidance", async ( } finally { directPlanner.mockRestore(); domainPlanner.mockRestore(); + fileSelector.mockRestore(); } }); diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index 38d505db8..be12cec0b 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -9,9 +9,9 @@ import { lstat } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { - planIOSAssociatedDomain, - type IOSAssociatedDomainBlockerCode, -} from "./associated-domain.ts"; + selectIOSEntitlementsFiles, + type IOSEntitlementsFileBlockerCode, +} from "./entitlements-files.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { @@ -35,7 +35,7 @@ const APPLE_SIGN_IN_VALUE = "Default"; const MAX_ENTITLEMENTS_BYTES = 1_000_000; export type IOSAppleEntitlementBlockerCode = - | IOSAssociatedDomainBlockerCode + | IOSEntitlementsFileBlockerCode | "conflicting-apple-entitlement" | "invalid-plan"; @@ -389,12 +389,11 @@ async function planIOSAppleEntitlementForPlatform( root: resolve(options.root), platform: options.platform ?? "ios", }; - const entitlementProbe = await planIOSAssociatedDomain({ + const entitlementProbe = await selectIOSEntitlementsFiles({ root: normalized.root, projectPath: normalized.projectPath, targetId: normalized.targetId, platform: normalized.platform, - deferToPublishableKey: true, allowMissingEntitlementsCreation: normalized.allowMissingEntitlementsCreation, allowSelectedTargetPlatformSharing: true, }); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 618cd786e..eb1014adc 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -1,26 +1,16 @@ +import { lstat } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { decodePublishableKey } from "../../../lib/fapi.ts"; import { bytesWithOptionalBOM, newEntitlementsBytes, lineIndentAt, stripXMLCommentsPreservingOffsets, entitlementKeyStructure, - decodeEntitlementsXML, } from "./entitlements-xml.ts"; -import { - generatedProjectKind, - selectedIOSAppTarget as selectedTarget, -} from "./project-selection.ts"; -import { lstat, readFile, realpath } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; -import { parse as parsePbxProject } from "@bacons/xcode/json"; -import { decodePublishableKey } from "../../../lib/fapi.ts"; +import { selectedIOSAppTarget as selectedTarget } from "./project-selection.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; -import { inspectTargetBuildConfigurations } from "./build-settings.ts"; -import { - discoverLocalIOSProjects, - pathIsSafelyWithinIOSRoot, - relativeIOSPath, -} from "./discovery.ts"; +import { relativeIOSPath } from "./discovery.ts"; import { applyIOSFileTransaction, hashIOSFileBytes, @@ -30,39 +20,26 @@ import { type IOSFileMutation, } from "./file-transaction.ts"; import { - planIOSMissingEntitlementsSettings, prepareIOSMissingEntitlementsSettingsMutation, validateIOSMissingEntitlementsSettingsPostcondition, type IOSMissingEntitlementsSettingsPlan, } from "./entitlements-settings.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; -import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; -import { resolveXcodeProjectDocument } from "./project-document.ts"; -import type { - IOSAppTarget, - IOSDiagnostic, - IOSNativePlatform, - IOSProjectInspectionResult, -} from "./types.ts"; -import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; -import { parseXCProjSource, xcprojTargets } from "./xcproj.ts"; +import type { IOSAppTarget, IOSNativePlatform, IOSProjectInspectionResult } from "./types.ts"; + +import { + inspectIOSEntitlementsFile, + selectIOSEntitlementsFiles, + type IOSEntitlementsFile, + type IOSEntitlementsFileBlockerCode, +} from "./entitlements-files.ts"; const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains"; const MAX_ENTITLEMENTS_BYTES = 1_000_000; export type IOSAssociatedDomainBlockerCode = - | "invalid-selection" - | "generated-project" - | "unresolved-platform" - | "runtime-key-unproven" - | "missing-entitlements" - | "mixed-entitlements" - | "unresolved-entitlements" - | "unsafe-entitlements" - | "unreadable-entitlements" - | "unsupported-entitlements" - | "shared-entitlements" - | "stale-entitlements"; + | IOSEntitlementsFileBlockerCode + | "runtime-key-unproven"; export interface IOSAssociatedDomainBlocker { code: IOSAssociatedDomainBlockerCode; @@ -101,7 +78,7 @@ export interface IOSAssociatedDomainPlanOptions { /** Invocation-root-relative selected .xcodeproj path. */ projectPath: string; targetId: string; - /** Defaults to iOS; capability planners may share these ownership checks on macOS. */ + /** Defaults to iOS. */ platform?: IOSNativePlatform; /** A separately proven direct Swift configuration will supply the runtime key after auth. */ deferToPublishableKey?: boolean; @@ -135,14 +112,7 @@ export interface IOSAssociatedDomainApplyResult { message?: string; } -interface EntitlementsFile { - absolutePath: string; - relativePath: string; - bytes: Uint8Array; - hash: string; - mode: number; - source: string; - bom: boolean; +interface EntitlementsFile extends IOSEntitlementsFile { domains: string[]; } @@ -199,410 +169,57 @@ async function inspectEntitlementsFile( root: string, absolutePath: string, ): Promise<{ file?: EntitlementsFile; blocker?: IOSAssociatedDomainBlocker }> { - if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + const inspected = await inspectIOSEntitlementsFile(root, absolutePath); + if (!inspected.file) return { blocker: inspected.blocker }; + const { file } = inspected; + const source = file.source; + const parsed = file.values; + const rawDomains = parsed[ASSOCIATED_DOMAINS_KEY]; + const semanticKeyStructure = entitlementKeyStructure(source, ASSOCIATED_DOMAINS_KEY); + const structuralKeyCount = semanticKeyStructure.literalCount; + if ( + rawDomains !== undefined && + (!Array.isArray(rawDomains) || rawDomains.some((value) => typeof value !== "string")) + ) { return { blocker: blocker( - "unsafe-entitlements", - `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} has a non-string Associated Domains value.`, ), }; } - - const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); - if (file.status === "not-regular" || file.status === "too-large") { + if ( + !semanticKeyStructure.safelyDecoded || + semanticKeyStructure.semanticCount > 1 || + structuralKeyCount > 1 || + (rawDomains !== undefined && + (structuralKeyCount !== 1 || semanticKeyStructure.semanticCount !== 1)) || + (rawDomains === undefined && + (structuralKeyCount !== 0 || semanticKeyStructure.semanticCount !== 0)) + ) { return { blocker: blocker( "unsupported-entitlements", `${relativeIOSPath( root, absolutePath, - )} must be a regular, non-symlink XML plist no larger than 1 MB.`, - ), - }; - } - if (file.status !== "ok") { - try { - const info = await lstat(absolutePath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_ENTITLEMENTS_BYTES) { - return { - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath( - root, - absolutePath, - )} must be a regular, non-symlink XML plist no larger than 1 MB.`, - ), - }; - } - } catch { - // Preserve the unreadable classification below when the current path - // cannot explain the bounded reader's failure. - } - return { - blocker: blocker( - "unreadable-entitlements", - `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + )} does not contain one safely editable literal Associated Domains key.`, ), }; } - - try { - const bytes = file.bytes; - if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { - return { - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath( - root, - absolutePath, - )} is a binary plist. Save it as XML before automatic setup.`, - ), - }; - } - const { source, bom, values: parsed } = decodeEntitlementsXML(bytes); - const rawDomains = parsed[ASSOCIATED_DOMAINS_KEY]; - const semanticKeyStructure = entitlementKeyStructure(source, ASSOCIATED_DOMAINS_KEY); - const structuralKeyCount = semanticKeyStructure.literalCount; - if ( - rawDomains !== undefined && - (!Array.isArray(rawDomains) || rawDomains.some((value) => typeof value !== "string")) - ) { - return { - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath(root, absolutePath)} has a non-string Associated Domains value.`, - ), - }; - } - if ( - !semanticKeyStructure.safelyDecoded || - semanticKeyStructure.semanticCount > 1 || - structuralKeyCount > 1 || - (rawDomains !== undefined && - (structuralKeyCount !== 1 || semanticKeyStructure.semanticCount !== 1)) || - (rawDomains === undefined && - (structuralKeyCount !== 0 || semanticKeyStructure.semanticCount !== 0)) - ) { - return { - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath( - root, - absolutePath, - )} does not contain one safely editable literal Associated Domains key.`, - ), - }; - } - const domains = (rawDomains as string[] | undefined) ?? []; - if (domains.some(hasUnresolvedDomain)) { - return { - blocker: blocker( - "unresolved-entitlements", - `${relativeIOSPath( - root, - absolutePath, - )} contains Associated Domains entries with unresolved build settings.`, - ), - }; - } - return { - file: { - absolutePath, - relativePath: relativeIOSPath(root, absolutePath), - bytes, - hash: hashIOSFileBytes(bytes), - mode: file.mode, - source, - bom, - domains, - }, - }; - } catch { + const domains = (rawDomains as string[] | undefined) ?? []; + if (domains.some(hasUnresolvedDomain)) { return { blocker: blocker( - "unreadable-entitlements", - `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + "unresolved-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} contains Associated Domains entries with unresolved build settings.`, ), }; } -} - -function normalizeObjects(value: unknown): PbxObjects | undefined { - if (!isRecord(value)) return undefined; - const objects: PbxObjects = {}; - for (const [id, object] of Object.entries(value)) { - if (isRecord(object)) objects[id] = object as PbxObject; - } - return objects; -} - -function exactStringArray(value: unknown): string[] | undefined { - return Array.isArray(value) && value.every((item) => typeof item === "string") - ? value - : undefined; -} - -async function ownershipIsExclusive( - root: string, - projectPath: string, - selectedTargetId: string, - selectedFiles: readonly EntitlementsFile[], - selectedPlatform: IOSNativePlatform, - allowSelectedTargetPlatformSharing = false, -): Promise { - try { - const selectedCanonical = new Set(); - const selectedInodes = new Set(); - for (const file of selectedFiles) { - const canonical = await realpath(file.absolutePath); - const info = await lstat(file.absolutePath); - const inode = `${info.dev}:${info.ino}`; - // Two selected configuration paths that resolve to the same file are - // not independent transaction targets. Refuse both symlink/canonical - // aliases and hard-link aliases rather than silently splitting them. - if (selectedCanonical.has(canonical) || selectedInodes.has(inode)) return false; - selectedCanonical.add(canonical); - selectedInodes.add(inode); - } - - const selectedProject = resolve(root, projectPath); - const inventory = await discoverLocalIOSProjects(root, [selectedProject]); - if (!inventory.complete) return false; - for (const absoluteProject of inventory.projectPaths) { - const documentResolution = await resolveXcodeProjectDocument(absoluteProject); - if (documentResolution.status !== "found") return false; - if (documentResolution.document.format === "xcproj") { - if (!(await pathIsSafelyWithinIOSRoot(root, documentResolution.document.absolutePath))) { - return false; - } - const projectFile = await readBoundedRegularFile( - documentResolution.document.absolutePath, - 15_000_000, - ); - if (projectFile.status !== "ok") return false; - const project = parseXCProjSource(projectFile.bytes).root; - const targets = xcprojTargets(project); - // The canonical JSON project path is safe when it has only the - // selected app target. Additional JSON targets are preserved but left - // for manual review until their non-application entitlement ownership - // can be modeled with the same guarantees as PBX targets. - if ( - absoluteProject !== selectedProject || - targets.length !== 1 || - targets[0]?.id !== selectedTargetId - ) { - return false; - } - - const target = targets[0]; - if (!target) return false; - const primaryDiagnostics: IOSDiagnostic[] = []; - const primaryConfigurations = await inspectXCProjTargetBuildConfigurations({ - root, - projectPath: absoluteProject, - projectDocumentPath: documentResolution.document.absolutePath, - project, - target, - diagnostics: primaryDiagnostics, - }); - if ( - primaryConfigurations.length === 0 || - primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || - primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") - ) { - return false; - } - if ( - !primaryConfigurations.every( - (configuration) => configuration.platform === primaryConfigurations[0]?.platform, - ) - ) { - return false; - } - - const primaryPlatform = primaryConfigurations[0]?.platform; - const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => - primaryConfigurations.some((configuration) => - configuration.supportedPlatforms.includes(platform), - ), - ); - const views: Array<{ - platform?: IOSNativePlatform; - configurations: typeof primaryConfigurations; - }> = [{ platform: primaryPlatform, configurations: primaryConfigurations }]; - for (const platform of supportedPlatforms) { - if (platform === primaryPlatform) continue; - const diagnostics: IOSDiagnostic[] = []; - const configurations = await inspectXCProjTargetBuildConfigurations({ - root, - projectPath: absoluteProject, - projectDocumentPath: documentResolution.document.absolutePath, - project, - target, - diagnostics, - platform, - }); - if ( - configurations.length !== primaryConfigurations.length || - configurations.some( - (configuration) => - !configuration.platformEvidenceComplete || configuration.platform !== platform, - ) || - diagnostics.some((diagnostic) => diagnostic.severity === "error") - ) { - return false; - } - views.push({ platform, configurations }); - } - - for (const view of views) { - if (view.platform === selectedPlatform || allowSelectedTargetPlatformSharing) { - continue; - } - for (const configuration of view.configurations) { - const resolution = configuration.model.entitlementsPath; - if (resolution.state === "unresolved") return false; - if (resolution.state !== "resolved") continue; - const siblingPath = resolve(dirname(absoluteProject), resolution.value); - if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; - try { - const canonical = await realpath(siblingPath); - const info = await lstat(siblingPath); - if ( - selectedCanonical.has(canonical) || - selectedInodes.has(`${info.dev}:${info.ino}`) - ) { - return false; - } - } catch { - // A missing sibling entitlements path cannot currently alias an existing selected file. - } - } - } - continue; - } - const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); - if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; - const info = await lstat(pbxprojPath); - if (!info.isFile() || info.isSymbolicLink() || info.size > 15_000_000) return false; - const bytes = new Uint8Array(await readFile(pbxprojPath)); - const archive = parsePbxProject(new TextDecoder().decode(bytes)); - const objects = normalizeObjects(archive.objects); - if (!objects) return false; - const rootObjectId = asString(archive.rootObject); - const projectObject = rootObjectId ? objects[rootObjectId] : undefined; - if (projectObject?.isa !== "PBXProject") return false; - const targetIds = exactStringArray(projectObject.targets); - if (!targetIds) return false; - const parents = buildPbxParentIndex(objects); - const groupRootDirectory = resolve( - dirname(absoluteProject), - asString(projectObject.projectDirPath) ?? "", - ); - - for (const targetId of targetIds) { - const targetObject = objects[targetId]; - if (!targetObject) return false; - if (targetObject.isa !== "PBXNativeTarget") continue; - const primaryDiagnostics: IOSDiagnostic[] = []; - const primaryConfigurations = await inspectTargetBuildConfigurations({ - root, - projectPath: absoluteProject, - groupRootDirectory, - projectObject, - targetId, - targetObject, - objects, - parents, - diagnostics: primaryDiagnostics, - }); - if ( - primaryConfigurations.length === 0 || - primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || - primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") - ) { - return false; - } - - if ( - !primaryConfigurations.every( - (configuration) => configuration.platform === primaryConfigurations[0]?.platform, - ) - ) { - return false; - } - const primaryPlatform = primaryConfigurations[0]?.platform; - const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => - primaryConfigurations.some((configuration) => - configuration.supportedPlatforms.includes(platform), - ), - ); - const views: Array<{ - platform?: IOSNativePlatform; - configurations: typeof primaryConfigurations; - }> = [{ platform: primaryPlatform, configurations: primaryConfigurations }]; - for (const platform of supportedPlatforms) { - if (platform === primaryPlatform) continue; - const diagnostics: IOSDiagnostic[] = []; - const configurations = await inspectTargetBuildConfigurations({ - root, - projectPath: absoluteProject, - groupRootDirectory, - projectObject, - targetId, - targetObject, - objects, - parents, - diagnostics, - platform, - }); - if ( - configurations.length !== primaryConfigurations.length || - configurations.some( - (configuration) => - !configuration.platformEvidenceComplete || configuration.platform !== platform, - ) || - diagnostics.some((diagnostic) => diagnostic.severity === "error") - ) { - return false; - } - views.push({ platform, configurations }); - } - - for (const view of views) { - if ( - absoluteProject === selectedProject && - targetId === selectedTargetId && - (view.platform === selectedPlatform || allowSelectedTargetPlatformSharing) - ) { - continue; - } - for (const configuration of view.configurations) { - const resolution = configuration.model.entitlementsPath; - if (resolution.state === "unresolved") return false; - if (resolution.state !== "resolved") continue; - const siblingPath = resolve(dirname(absoluteProject), resolution.value); - if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; - try { - const canonical = await realpath(siblingPath); - const info = await lstat(siblingPath); - if ( - selectedCanonical.has(canonical) || - selectedInodes.has(`${info.dev}:${info.ino}`) - ) { - return false; - } - } catch { - // A missing sibling entitlements path cannot currently alias an existing selected file. - } - } - } - } - } - return true; - } catch { - return false; - } + return { file: { ...file, domains } }; } export function associatedDomainMatches(actual: string, expected: string): boolean { @@ -627,10 +244,7 @@ function exactDomainPresent(domains: readonly string[], expectedDomain: string): return domains.some((domain) => associatedDomainMatches(domain, expectedDomain)); } -/** - * Plans the conservative v1 Associated Domains edit. It only patches existing - * XML entitlements files that cover every selected-target configuration. - */ +/** Plans only the Associated Domains value after capability-neutral file selection. */ export async function planIOSAssociatedDomain( options: IOSAssociatedDomainPlanOptions, ): Promise { @@ -641,42 +255,11 @@ export async function planIOSAssociatedDomain( exhaustiveContainerDiscovery: true, platform, }); - const target = selectedTarget(inspection, options.projectPath, options.targetId); - if (!target) { - return blockedPlan(options, [ - blocker("invalid-selection", "The selected iOS target could not be resolved exactly."), - ]); + const selection = await selectIOSEntitlementsFiles(options, inspection); + if (selection.status === "blocked") { + return blockedPlan(options, selection.blockers, selection.targetName); } - if (!target.platformEvidenceComplete) { - return blockedPlan( - options, - [ - blocker( - "unresolved-platform", - "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing entitlements.", - ), - ], - target.name, - ); - } - const generator = - inspection.generatedProject ?? - (await generatedProjectKind(root, resolve(root, options.projectPath))); - if (generator) { - return blockedPlan( - options, - [ - blocker( - "generated-project", - `This is a ${ - generator === "xcodegen" ? "XcodeGen" : "Tuist" - } project; update its source manifest instead of generated entitlements.`, - ), - ], - target.name, - ); - } - + const target = selectedTarget(inspection, options.projectPath, options.targetId)!; const host = runtimeFrontendHost(inspection, target); if (!host && !options.deferToPublishableKey) { return blockedPlan( @@ -690,166 +273,26 @@ export async function planIOSAssociatedDomain( target.name, ); } - - if (target.configurations.length === 0) { - return blockedPlan( - options, - [ - blocker( - "missing-entitlements", - "The selected target has no inspectable build configurations.", - ), - ], - target.name, - ); - } const expectedDomain = host ? `webcredentials:${host}` : undefined; - const resolvedPaths = target.configurations.flatMap((configuration) => - configuration.entitlementsPath.state === "resolved" - ? [configuration.entitlementsPath.value] - : [], - ); - if (resolvedPaths.length === 0) { - if ( - target.configurations.some( - (configuration) => configuration.entitlementsPath.state !== "missing", - ) - ) { - return blockedPlan( - options, - [ - blocker( - "unresolved-entitlements", - "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", - ), - ], - target.name, - ); - } - if (options.allowMissingEntitlementsCreation) { - const settingsPlan = await planIOSMissingEntitlementsSettings({ - root, - projectPath: options.projectPath, - targetId: options.targetId, - platform, - }); - if (settingsPlan.status === "ready" && settingsPlan.entitlementsPath) { - return { - schemaVersion: 1, - kind: "clerk-ios-associated-domain", - status: "ready", - root, - projectPath: options.projectPath, - targetId: options.targetId, - platform, - targetName: target.name, - ...(expectedDomain ? { expectedDomain } : {}), - requiresPublishableKey: expectedDomain == null, - files: [{ path: settingsPlan.entitlementsPath, operation: "create" }], - missingEntitlementsSettings: settingsPlan, - actions: [ - expectedDomain - ? `Create ${settingsPlan.entitlementsPath} with ${expectedDomain}.` - : `Create ${settingsPlan.entitlementsPath} with the linked development instance's exact webcredentials host (resolved after authentication).`, - platform === "macos" - ? `Attach ${settingsPlan.entitlementsPath} only to macOS SDK builds for every selected-target configuration.` - : `Attach ${settingsPlan.entitlementsPath} only to iPhone and iPad SDK builds for every selected-target configuration.`, - ], - blockers: [], - }; - } + const files: EntitlementsFile[] = []; + for (const selected of selection.files) { + if (selected.operation === "create") continue; + const inspected = await inspectEntitlementsFile(root, resolve(root, selected.path)); + if (inspected.blocker) return blockedPlan(options, [inspected.blocker], target.name); + if (!inspected.file || inspected.file.hash !== selected.expectedHash) { return blockedPlan( options, - settingsPlan.blockers.length > 0 - ? settingsPlan.blockers.map((item) => blocker("missing-entitlements", item.message)) - : [ - blocker( - "missing-entitlements", - "The missing-entitlements plan did not identify one safe destination.", - ), - ], + [blocker("stale-entitlements", `${selected.path} changed while setup was inspected.`)], target.name, ); } - return blockedPlan( - options, - [ - blocker( - "missing-entitlements", - "No selected-target configuration has an existing entitlements file, and this runtime route cannot safely create one automatically.", - ), - ], - target.name, - ); - } - if (resolvedPaths.length !== target.configurations.length) { - return blockedPlan( - options, - [ - blocker( - "mixed-entitlements", - "Some selected-target configurations have entitlements while others do not. Choose the intended files in Xcode before automatic setup.", - ), - ], - target.name, - ); - } - if ( - target.configurations.some( - (configuration) => configuration.entitlementsPath.state !== "resolved", - ) - ) { - return blockedPlan( - options, - [ - blocker( - "unresolved-entitlements", - "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", - ), - ], - target.name, - ); - } - - const filesByPath = new Map(); - const blockers: IOSAssociatedDomainBlocker[] = []; - for (const configuredPath of new Set(resolvedPaths)) { - const absolutePath = resolve(root, options.projectPath, "..", configuredPath); - const inspected = await inspectEntitlementsFile(root, absolutePath); - if (inspected.blocker) blockers.push(inspected.blocker); - if (inspected.file) filesByPath.set(inspected.file.absolutePath, inspected.file); - } - if (blockers.length > 0 || filesByPath.size !== new Set(resolvedPaths).size) { - return blockedPlan(options, blockers, target.name); - } - const files = [...filesByPath.values()].sort((a, b) => - a.relativePath.localeCompare(b.relativePath), - ); - if ( - !(await ownershipIsExclusive( - root, - options.projectPath, - options.targetId, - files, - platform, - options.allowSelectedTargetPlatformSharing, - )) - ) { - return blockedPlan( - options, - [ - blocker( - "shared-entitlements", - "An entitlements file may be shared with another target, or exclusive ownership could not be proven.", - ), - ], - target.name, - ); + files.push(inspected.file); } - const satisfied = expectedDomain != null && + selection.files.every((file) => file.operation === "modify") && files.every((file) => exactDomainPresent(file.domains, expectedDomain)); + const settingsPlan = selection.missingEntitlementsSettings; return { schemaVersion: 1, kind: "clerk-ios-associated-domain", @@ -861,18 +304,24 @@ export async function planIOSAssociatedDomain( targetName: target.name, ...(expectedDomain ? { expectedDomain } : {}), requiresPublishableKey: expectedDomain == null, - files: files.map((file) => ({ - path: file.relativePath, - operation: "modify" as const, - expectedHash: file.hash, - })), + files: selection.files, + ...(settingsPlan ? { missingEntitlementsSettings: settingsPlan } : {}), actions: satisfied ? [] - : [ - expectedDomain - ? `Add ${expectedDomain} to every selected-target entitlements configuration.` - : "Add the linked development instance's exact webcredentials host to every selected-target entitlements configuration (host resolved after authentication).", - ], + : settingsPlan + ? [ + expectedDomain + ? `Create ${settingsPlan.entitlementsPath} with ${expectedDomain}.` + : `Create ${settingsPlan.entitlementsPath} with the linked development instance's exact webcredentials host (resolved after authentication).`, + platform === "macos" + ? `Attach ${settingsPlan.entitlementsPath} only to macOS SDK builds for every selected-target configuration.` + : `Attach ${settingsPlan.entitlementsPath} only to iPhone and iPad SDK builds for every selected-target configuration.`, + ] + : [ + expectedDomain + ? `Add ${expectedDomain} to every selected-target entitlements configuration.` + : "Add the linked development instance's exact webcredentials host to every selected-target entitlements configuration (host resolved after authentication).", + ], blockers: [], }; } @@ -1146,15 +595,6 @@ export async function validatePreparedIOSAssociatedDomain( if (hasIncompleteIOSContainerDiscovery(inspection)) return false; const target = selectedTarget(inspection, prepared.plan.projectPath, prepared.plan.targetId); if (!target?.platformEvidenceComplete) return false; - if ( - inspection.generatedProject != null || - (await generatedProjectKind( - prepared.plan.root, - resolve(prepared.plan.root, prepared.plan.projectPath), - )) != null - ) { - return false; - } const expectedHost = prepared.expectedDomain.slice("webcredentials:".length); if ( !prepared.plan.requiresPublishableKey && @@ -1162,29 +602,29 @@ export async function validatePreparedIOSAssociatedDomain( ) { return false; } - if (target.configurations.length === 0) return false; - const files: EntitlementsFile[] = []; - for (const configuration of target.configurations) { - if (configuration.entitlementsPath.state !== "resolved") return false; - const absolutePath = resolve( + const selection = await selectIOSEntitlementsFiles( + { + root: prepared.plan.root, + projectPath: prepared.plan.projectPath, + targetId: prepared.plan.targetId, + platform: prepared.plan.platform, + }, + inspection, + ); + if (selection.status === "blocked") return false; + for (const file of selection.files) { + const inspected = await inspectEntitlementsFile( prepared.plan.root, - prepared.plan.projectPath, - "..", - configuration.entitlementsPath.value, + resolve(prepared.plan.root, file.path), ); - const inspected = await inspectEntitlementsFile(prepared.plan.root, absolutePath); - if (!inspected.file || !exactDomainPresent(inspected.file.domains, prepared.expectedDomain)) { + if ( + !inspected.file || + inspected.file.hash !== file.expectedHash || + !exactDomainPresent(inspected.file.domains, prepared.expectedDomain) + ) return false; - } - files.push(inspected.file); } - return ownershipIsExclusive( - prepared.plan.root, - prepared.plan.projectPath, - prepared.plan.targetId, - [...new Map(files.map((file) => [file.absolutePath, file])).values()], - prepared.plan.platform, - ); + return true; } export async function applyIOSAssociatedDomain( diff --git a/packages/cli-core/src/commands/init/ios/entitlements-files.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-files.test.ts new file mode 100644 index 000000000..9ca53abff --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/entitlements-files.test.ts @@ -0,0 +1,156 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applyIOSAppleEntitlement, planIOSAppleEntitlement } from "./apple-entitlement.ts"; +import { applyMacOSNetworkCapability, planMacOSNetworkCapability } from "./macos-network.ts"; +import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { + createIOSFixture, + createIOSJSONFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import { applyXCProjValue } from "./xcproj.ts"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +for (const format of ["pbx", "json"] as const) { + for (const capability of ["apple", "network"] as const) { + test.each([ + "webcredentials:$(CUSTOM_FAPI)", + "42", + ])( + `${format} ${capability} preserves an unrelated Associated Domains issue: %s`, + async (domainValue) => { + const root = await mkdtemp(join(tmpdir(), "clerk-entitlements-ownership-")); + roots.push(root); + if (format === "json") { + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await Bun.file(projectPath).text(); + project = applyXCProjValue(project, ["build-settings", "SDKROOT"], "macosx"); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "SUPPORTED_PLATFORMS"], + "macosx", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "MACOSX_DEPLOYMENT_TARGET"], + "14.0", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "ENABLE_APP_SANDBOX"], + "YES", + ); + await Bun.write(projectPath, project); + } else { + await createIOSFixture(root, { platform: "macos", macOSAppleEntitlement: false }); + } + const path = join(root, "MyApp", "MyApp.entitlements"); + const domain = `com.apple.developer.associated-domains${domainValue}`; + await Bun.write( + path, + `com.apple.security.app-sandbox${domain}`, + ); + const options = { + root, + projectPath: "MyApp.xcodeproj", + targetId: format === "json" ? "C1E000000000000000000001" : IOS_FIXTURE_IDS.appTarget, + platform: "macos" as const, + }; + const before = await treeDigest(root); + const associated = await planIOSAssociatedDomain({ + ...options, + deferToPublishableKey: true, + }); + expect(associated.status).toBe("blocked"); + expect(associated.blockers[0]?.message).toContain("Associated Domains"); + expect(await treeDigest(root)).toEqual(before); + const plan = + capability === "apple" + ? await planIOSAppleEntitlement(options) + : await planMacOSNetworkCapability(options); + expect(plan.status, JSON.stringify(plan.blockers)).toBe("ready"); + const result = + capability === "apple" + ? await applyIOSAppleEntitlement( + plan as Awaited>, + ) + : await applyMacOSNetworkCapability( + plan as Awaited>, + ); + expect(result.status).toBe("applied"); + const source = await Bun.file(path).text(); + expect(source).toContain(domain); + expect(source).toContain( + capability === "apple" + ? "com.apple.developer.applesignin" + : "com.apple.security.network.client", + ); + const applied = await treeDigest(root); + const rerun = + capability === "apple" + ? await applyIOSAppleEntitlement(await planIOSAppleEntitlement(options)) + : await applyMacOSNetworkCapability(await planMacOSNetworkCapability(options)); + expect(rerun.status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(applied); + }, + ); + } +} + +for (const capability of ["apple", "network"] as const) { + test.each(["shared", "unproven"])( + `${capability} still refuses %s entitlement ownership with an unrelated domain issue`, + async (ownership) => { + const root = await mkdtemp(join(tmpdir(), "clerk-entitlements-shared-")); + roots.push(root); + await createIOSFixture(root, { + platform: "macos", + macOSAppleEntitlement: false, + secondTarget: true, + }); + const path = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + path, + 'com.apple.security.app-sandboxcom.apple.developer.associated-domains42', + ); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + let project = await Bun.file(projectPath).text(); + 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 = ${ownership === "shared" ? "MyApp/MyApp.entitlements" : '"$(UNKNOWN_ENTITLEMENTS)"'}; `, + ); + } + await Bun.write(projectPath, project); + const before = await treeDigest(root); + const options = { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + platform: "macos" as const, + }; + const result = + capability === "apple" + ? await applyIOSAppleEntitlement(await planIOSAppleEntitlement(options)) + : await applyMacOSNetworkCapability(await planMacOSNetworkCapability(options)); + expect(result.status).toBe("blocked"); + expect(result.plan.blockers).toContainEqual( + expect.objectContaining({ + code: capability === "apple" ? "shared-entitlements" : "unsafe-entitlements", + message: + "An entitlements file may be shared with another target, or exclusive ownership could not be proven.", + }), + ); + expect(await treeDigest(root)).toEqual(before); + }, + ); +} diff --git a/packages/cli-core/src/commands/init/ios/entitlements-files.ts b/packages/cli-core/src/commands/init/ios/entitlements-files.ts new file mode 100644 index 000000000..e0a41f6ff --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/entitlements-files.ts @@ -0,0 +1,656 @@ +import { lstat, readFile, realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { + generatedProjectKind, + selectedIOSAppTarget as selectedTarget, +} from "./project-selection.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; +import { inspectTargetBuildConfigurations } from "./build-settings.ts"; +import { + discoverLocalIOSProjects, + pathIsSafelyWithinIOSRoot, + relativeIOSPath, +} from "./discovery.ts"; +import { hashIOSFileBytes } from "./file-transaction.ts"; +import { + planIOSMissingEntitlementsSettings, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; +import { decodeEntitlementsXML } from "./entitlements-xml.ts"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; +import type { IOSDiagnostic, IOSNativePlatform, IOSProjectInspectionResult } from "./types.ts"; +import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; +import { parseXCProjSource, xcprojTargets } from "./xcproj.ts"; + +const MAX_ENTITLEMENTS_BYTES = 1_000_000; +export type IOSEntitlementsFileBlockerCode = + | "invalid-selection" + | "generated-project" + | "unresolved-platform" + | "missing-entitlements" + | "mixed-entitlements" + | "unresolved-entitlements" + | "unsafe-entitlements" + | "unreadable-entitlements" + | "unsupported-entitlements" + | "shared-entitlements" + | "stale-entitlements"; + +export interface IOSEntitlementsFileBlocker { + code: IOSEntitlementsFileBlockerCode; + message: string; +} + +export interface IOSEntitlementsPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface IOSEntitlementsFileOptions { + root: string; + projectPath: string; + targetId: string; + platform?: IOSNativePlatform; + allowMissingEntitlementsCreation?: boolean; + /** Only capabilities valid on every selected-target platform may opt in. */ + allowSelectedTargetPlatformSharing?: boolean; +} + +export interface IOSEntitlementsFileSelection { + status: "ready" | "blocked"; + targetName?: string; + files: IOSEntitlementsPlanFile[]; + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + blockers: IOSEntitlementsFileBlocker[]; +} + +/** Internal file evidence; never include source or plist values in serialized plans. */ +export interface IOSEntitlementsFile { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + values: Record; +} + +function blocker( + code: IOSEntitlementsFileBlockerCode, + message: string, +): IOSEntitlementsFileBlocker { + return { code, message }; +} + +function blockedSelection( + blockers: IOSEntitlementsFileBlocker[], + targetName?: string, +): IOSEntitlementsFileSelection { + return { status: "blocked", ...(targetName ? { targetName } : {}), files: [], blockers }; +} + +export async function inspectIOSEntitlementsFile( + root: string, + absolutePath: string, +): Promise<{ file?: IOSEntitlementsFile; blocker?: IOSEntitlementsFileBlocker }> { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "not-regular" || file.status === "too-large") { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + if (file.status !== "ok") { + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_ENTITLEMENTS_BYTES) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + } catch { + // Preserve the unreadable classification below when the current path + // cannot explain the bounded reader's failure. + } + return { + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } + + try { + const bytes = file.bytes; + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath( + root, + absolutePath, + )} is a binary plist. Save it as XML before automatic setup.`, + ), + }; + } + const { source, bom, values: parsed } = decodeEntitlementsXML(bytes); + return { + file: { + absolutePath, + relativePath: relativeIOSPath(root, absolutePath), + bytes, + hash: hashIOSFileBytes(bytes), + mode: file.mode, + source, + bom, + values: parsed, + }, + }; + } catch { + return { + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +function normalizeObjects(value: unknown): PbxObjects | undefined { + if (!isRecord(value)) return undefined; + const objects: PbxObjects = {}; + for (const [id, object] of Object.entries(value)) { + if (isRecord(object)) objects[id] = object as PbxObject; + } + return objects; +} + +function exactStringArray(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? value + : undefined; +} + +async function ownershipIsExclusive( + root: string, + projectPath: string, + selectedTargetId: string, + selectedFiles: readonly IOSEntitlementsFile[], + selectedPlatform: IOSNativePlatform, + allowSelectedTargetPlatformSharing = false, +): Promise { + try { + const selectedCanonical = new Set(); + const selectedInodes = new Set(); + for (const file of selectedFiles) { + const canonical = await realpath(file.absolutePath); + const info = await lstat(file.absolutePath); + const inode = `${info.dev}:${info.ino}`; + // Two selected configuration paths that resolve to the same file are + // not independent transaction targets. Refuse both symlink/canonical + // aliases and hard-link aliases rather than silently splitting them. + if (selectedCanonical.has(canonical) || selectedInodes.has(inode)) return false; + selectedCanonical.add(canonical); + selectedInodes.add(inode); + } + + const selectedProject = resolve(root, projectPath); + const inventory = await discoverLocalIOSProjects(root, [selectedProject]); + if (!inventory.complete) return false; + for (const absoluteProject of inventory.projectPaths) { + const documentResolution = await resolveXcodeProjectDocument(absoluteProject); + if (documentResolution.status !== "found") return false; + if (documentResolution.document.format === "xcproj") { + if (!(await pathIsSafelyWithinIOSRoot(root, documentResolution.document.absolutePath))) { + return false; + } + const projectFile = await readBoundedRegularFile( + documentResolution.document.absolutePath, + 15_000_000, + ); + if (projectFile.status !== "ok") return false; + const project = parseXCProjSource(projectFile.bytes).root; + const targets = xcprojTargets(project); + // The canonical JSON project path is safe when it has only the + // selected app target. Additional JSON targets are preserved but left + // for manual review until their non-application entitlement ownership + // can be modeled with the same guarantees as PBX targets. + if ( + absoluteProject !== selectedProject || + targets.length !== 1 || + targets[0]?.id !== selectedTargetId + ) { + return false; + } + + const target = targets[0]; + if (!target) return false; + const primaryDiagnostics: IOSDiagnostic[] = []; + const primaryConfigurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + projectDocumentPath: documentResolution.document.absolutePath, + project, + target, + diagnostics: primaryDiagnostics, + }); + if ( + primaryConfigurations.length === 0 || + primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || + primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + if ( + !primaryConfigurations.every( + (configuration) => configuration.platform === primaryConfigurations[0]?.platform, + ) + ) { + return false; + } + + const primaryPlatform = primaryConfigurations[0]?.platform; + const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => + primaryConfigurations.some((configuration) => + configuration.supportedPlatforms.includes(platform), + ), + ); + const views: Array<{ + platform?: IOSNativePlatform; + configurations: typeof primaryConfigurations; + }> = [{ platform: primaryPlatform, configurations: primaryConfigurations }]; + for (const platform of supportedPlatforms) { + if (platform === primaryPlatform) continue; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + projectDocumentPath: documentResolution.document.absolutePath, + project, + target, + diagnostics, + platform, + }); + if ( + configurations.length !== primaryConfigurations.length || + configurations.some( + (configuration) => + !configuration.platformEvidenceComplete || configuration.platform !== platform, + ) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + views.push({ platform, configurations }); + } + + for (const view of views) { + if (view.platform === selectedPlatform || allowSelectedTargetPlatformSharing) { + continue; + } + for (const configuration of view.configurations) { + const resolution = configuration.model.entitlementsPath; + if (resolution.state === "unresolved") return false; + if (resolution.state !== "resolved") continue; + const siblingPath = resolve(dirname(absoluteProject), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + try { + const canonical = await realpath(siblingPath); + const info = await lstat(siblingPath); + if ( + selectedCanonical.has(canonical) || + selectedInodes.has(`${info.dev}:${info.ino}`) + ) { + return false; + } + } catch { + // A missing sibling entitlements path cannot currently alias an existing selected file. + } + } + } + continue; + } + const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); + if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; + const info = await lstat(pbxprojPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > 15_000_000) return false; + const bytes = new Uint8Array(await readFile(pbxprojPath)); + const archive = parsePbxProject(new TextDecoder().decode(bytes)); + const objects = normalizeObjects(archive.objects); + if (!objects) return false; + const rootObjectId = asString(archive.rootObject); + const projectObject = rootObjectId ? objects[rootObjectId] : undefined; + if (projectObject?.isa !== "PBXProject") return false; + const targetIds = exactStringArray(projectObject.targets); + if (!targetIds) return false; + const parents = buildPbxParentIndex(objects); + const groupRootDirectory = resolve( + dirname(absoluteProject), + asString(projectObject.projectDirPath) ?? "", + ); + + for (const targetId of targetIds) { + const targetObject = objects[targetId]; + if (!targetObject) return false; + if (targetObject.isa !== "PBXNativeTarget") continue; + const primaryDiagnostics: IOSDiagnostic[] = []; + const primaryConfigurations = await inspectTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics: primaryDiagnostics, + }); + if ( + primaryConfigurations.length === 0 || + primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || + primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + + if ( + !primaryConfigurations.every( + (configuration) => configuration.platform === primaryConfigurations[0]?.platform, + ) + ) { + return false; + } + const primaryPlatform = primaryConfigurations[0]?.platform; + const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => + primaryConfigurations.some((configuration) => + configuration.supportedPlatforms.includes(platform), + ), + ); + const views: Array<{ + platform?: IOSNativePlatform; + configurations: typeof primaryConfigurations; + }> = [{ platform: primaryPlatform, configurations: primaryConfigurations }]; + for (const platform of supportedPlatforms) { + if (platform === primaryPlatform) continue; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics, + platform, + }); + if ( + configurations.length !== primaryConfigurations.length || + configurations.some( + (configuration) => + !configuration.platformEvidenceComplete || configuration.platform !== platform, + ) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + views.push({ platform, configurations }); + } + + for (const view of views) { + if ( + absoluteProject === selectedProject && + targetId === selectedTargetId && + (view.platform === selectedPlatform || allowSelectedTargetPlatformSharing) + ) { + continue; + } + for (const configuration of view.configurations) { + const resolution = configuration.model.entitlementsPath; + if (resolution.state === "unresolved") return false; + if (resolution.state !== "resolved") continue; + const siblingPath = resolve(dirname(absoluteProject), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + try { + const canonical = await realpath(siblingPath); + const info = await lstat(siblingPath); + if ( + selectedCanonical.has(canonical) || + selectedInodes.has(`${info.dev}:${info.ino}`) + ) { + return false; + } + } catch { + // A missing sibling entitlements path cannot currently alias an existing selected file. + } + } + } + } + } + return true; + } catch { + return false; + } +} + +/** Select safe XML files and prove ownership without interpreting capability values. */ +export async function selectIOSEntitlementsFiles( + options: IOSEntitlementsFileOptions, + preparedInspection?: IOSProjectInspectionResult, +): Promise { + const root = resolve(options.root); + const platform = options.platform ?? "ios"; + const inspection = + preparedInspection ?? + (await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + platform, + })); + const target = selectedTarget(inspection, options.projectPath, options.targetId); + if (!target) { + return blockedSelection([ + blocker( + "invalid-selection", + "The selected native Apple target could not be resolved exactly.", + ), + ]); + } + if (!target.platformEvidenceComplete) { + return blockedSelection( + [ + blocker( + "unresolved-platform", + "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing entitlements.", + ), + ], + target.name, + ); + } + const generator = + inspection.generatedProject ?? + (await generatedProjectKind(root, resolve(root, options.projectPath))); + if (generator) { + return blockedSelection( + [ + blocker( + "generated-project", + `This is a ${ + generator === "xcodegen" ? "XcodeGen" : "Tuist" + } project; update its source manifest instead of generated entitlements.`, + ), + ], + target.name, + ); + } + + if (target.configurations.length === 0) { + return blockedSelection( + [ + blocker( + "missing-entitlements", + "The selected target has no inspectable build configurations.", + ), + ], + target.name, + ); + } + const resolvedPaths = target.configurations.flatMap((configuration) => + configuration.entitlementsPath.state === "resolved" + ? [configuration.entitlementsPath.value] + : [], + ); + if (resolvedPaths.length === 0) { + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "missing", + ) + ) { + return blockedSelection( + [ + blocker( + "unresolved-entitlements", + "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", + ), + ], + target.name, + ); + } + if (options.allowMissingEntitlementsCreation) { + const settingsPlan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: options.projectPath, + targetId: options.targetId, + platform, + }); + if (settingsPlan.status === "ready" && settingsPlan.entitlementsPath) { + return { + status: "ready", + targetName: target.name, + files: [{ path: settingsPlan.entitlementsPath, operation: "create" }], + missingEntitlementsSettings: settingsPlan, + blockers: [], + }; + } + return blockedSelection( + settingsPlan.blockers.length > 0 + ? settingsPlan.blockers.map((item) => blocker("missing-entitlements", item.message)) + : [ + blocker( + "missing-entitlements", + "The missing-entitlements plan did not identify one safe destination.", + ), + ], + target.name, + ); + } + return blockedSelection( + [ + blocker( + "missing-entitlements", + "No selected-target configuration has an existing entitlements file, and automatic file creation was not enabled.", + ), + ], + target.name, + ); + } + if (resolvedPaths.length !== target.configurations.length) { + return blockedSelection( + [ + blocker( + "mixed-entitlements", + "Some selected-target configurations have entitlements while others do not. Choose the intended files in Xcode before automatic setup.", + ), + ], + target.name, + ); + } + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "resolved", + ) + ) { + return blockedSelection( + [ + blocker( + "unresolved-entitlements", + "One or more CODE_SIGN_ENTITLEMENTS settings could not be resolved exactly.", + ), + ], + target.name, + ); + } + + const filesByPath = new Map(); + const blockers: IOSEntitlementsFileBlocker[] = []; + for (const configuredPath of new Set(resolvedPaths)) { + const absolutePath = resolve(root, options.projectPath, "..", configuredPath); + const inspected = await inspectIOSEntitlementsFile(root, absolutePath); + if (inspected.blocker) blockers.push(inspected.blocker); + if (inspected.file) filesByPath.set(inspected.file.absolutePath, inspected.file); + } + if (blockers.length > 0 || filesByPath.size !== new Set(resolvedPaths).size) { + return blockedSelection(blockers, target.name); + } + const files = [...filesByPath.values()].sort((a, b) => + a.relativePath.localeCompare(b.relativePath), + ); + if ( + !(await ownershipIsExclusive( + root, + options.projectPath, + options.targetId, + files, + platform, + options.allowSelectedTargetPlatformSharing, + )) + ) { + return blockedSelection( + [ + blocker( + "shared-entitlements", + "An entitlements file may be shared with another target, or exclusive ownership could not be proven.", + ), + ], + target.name, + ); + } + + return { + status: "ready", + targetName: target.name, + files: files.map((file) => ({ + path: file.relativePath, + operation: "modify" as const, + expectedHash: file.hash, + })), + blockers: [], + }; +} diff --git a/packages/cli-core/src/commands/init/ios/macos-network.ts b/packages/cli-core/src/commands/init/ios/macos-network.ts index 8cca85d80..e6b6ca779 100644 --- a/packages/cli-core/src/commands/init/ios/macos-network.ts +++ b/packages/cli-core/src/commands/init/ios/macos-network.ts @@ -9,7 +9,7 @@ import { selectedIOSAppTarget as selectedTarget } from "./project-selection.ts"; import { lstat } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; -import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { selectIOSEntitlementsFiles } from "./entitlements-files.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { @@ -655,12 +655,11 @@ export async function planMacOSNetworkCapability( }; } - const ownershipProbe = await planIOSAssociatedDomain({ + const ownershipProbe = await selectIOSEntitlementsFiles({ root: normalized.root, projectPath: normalized.projectPath, targetId: normalized.targetId, platform: "macos", - deferToPublishableKey: true, }); if (ownershipProbe.status === "blocked") { return blockedPlan( From c50ad2a6aa8033015c80ebee4b83e9a21cbcc848 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 15:49:45 -0400 Subject: [PATCH 04/16] docs(ios): clarify operation-specific setup boundaries --- docs/native-established-apps.md | 22 ++++++++++++------- .../cli-core/src/commands/doctor/README.md | 5 +++++ packages/cli-core/src/commands/init/README.md | 2 ++ 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md index e3b0893c6..86703395a 100644 --- a/docs/native-established-apps.md +++ b/docs/native-established-apps.md @@ -10,14 +10,20 @@ runtime wiring is unresolved, it does not activate automatic AuthView setup or provider-capability changes. SDK linkage and native registration may still proceed. Explicit `--prebuilt-auth-ui` requests retain the runtime checks. -| Operation | Required evidence | Effect of uncertain custom startup wiring | -| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| Link SDK products | Exhaustive target discovery, consistent platform views, complete source membership for product selection, and a safe package/project edit | May proceed when those prerequisites hold; preserve Swift | -| Register the native app during init | Explicit Clerk app selection for custom configuration, one Bundle ID across configurations/platforms, verified App ID Prefix, and additive remote reconciliation | May proceed when the local preflight and identity checks pass; does not verify runtime configuration | -| Doctor registration check | Linked Clerk app/development instance and independently verified target/platform identity; registration audit must resolve or report prefix ambiguity | May run despite incomplete Swift membership; reports only Native API and registration state | -| Rewrite runtime configuration or insert prebuilt UI | Proven runtime/source ownership and the relevant source plan's existing checks | Remains blocked; no parser expansion or inferred startup execution | -| Configure associated domains | Proven domain/key inputs and the capability planner's ownership checks | An unproven custom startup call does not supply a domain; preserve existing entitlements and report manual follow-up | -| Diagnose key matching, AuthView, or Apple authentication | The relevant runtime, source, entitlement, and linked-app evidence | Doctor's registration-only fallback does not run these checks or imply they passed | +Entitlement edits require safe file selection and proven ownership. An unresolved +or malformed Associated Domains value blocks editing that capability, but does +not by itself block a Sign in with Apple or macOS networking edit in the same +valid XML dictionary. Shared files, unresolved paths, and invalid plist structure +still block edits. Any permitted edit preserves unrelated capability values. + +| Operation | Required evidence | Effect of uncertain custom startup wiring | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Link SDK products | Exhaustive target discovery, consistent platform views, complete source membership for product selection, and a safe package/project edit | May proceed when those prerequisites hold; preserve Swift | +| Register the native app during init | Explicit Clerk app selection for custom configuration, one Bundle ID across configurations/platforms, verified App ID Prefix, and additive remote reconciliation | May proceed when the local preflight and identity checks pass; does not verify runtime configuration | +| Doctor registration check | Linked Clerk app/development instance and independently verified target/platform identity; registration audit must resolve or report prefix ambiguity | May run despite incomplete Swift membership; reports only Native API and registration state | +| Rewrite runtime configuration or insert prebuilt UI | Proven runtime/source ownership and the relevant source plan's existing checks | Remains blocked; no parser expansion or inferred startup execution | +| Configure associated domains | Proven domain/key inputs and the capability planner's ownership checks | An unproven custom startup call does not supply a domain; preserve domain values and report manual follow-up | +| Diagnose key matching, AuthView, or Apple authentication | The relevant runtime, source, entitlement, and linked-app evidence | Doctor's registration-only fallback does not run these checks or imply they passed | `init` still rejects incomplete source discovery before edits: its SDK choice and combined local plan depend on that evidence. Doctor is read-only and can retain a diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 5036e196e..23275c273 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -73,6 +73,11 @@ in human or JSON output. AuthView, Native Application, and Apple remote checks are GET-only. Their remedies point back to `clerk init`; `doctor --fix` never enables an auth strategy or changes Native Application state. +Incomplete Swift source discovery remains a diagnostic failure, but does not +suppress a Native Application check when target identity is independently proven +across all supported platforms. That registration-only check does not establish +runtime-key matching, AuthView compatibility, or Apple authentication readiness. + `clerk doctor` inspects configuration and remote Clerk state without invoking Xcode package resolution, builds, or Simulator execution. Build and runtime verification remain with Xcode and the project's existing test workflow. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index e51a31401..6c3ff46ac 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -72,6 +72,8 @@ For a safely inspectable fresh SwiftUI target, the same command selects or creat An existing custom `Clerk.configure(...)` source is never migrated or rewritten. The developer must explicitly select the existing Clerk application it belongs to; agents do this with `--app `. That choice authorizes linked-app and Native Application setup, but the CLI does not inspect the custom value or claim that it matches the selected application. +When custom startup execution remains unproven, SDK linkage and native registration may still proceed if their own prerequisites are satisfied, including when AuthView already exists. Runtime verification remains a manual follow-up; explicitly requesting AuthView setup still requires its runtime prerequisites. + 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 native Apple 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. From ef98dceae613306b46d1133f958e0b9c08fa1bb0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 15:49:46 -0400 Subject: [PATCH 05/16] docs(changeset): describe established native app setup fixes --- .changeset/ios-established-app-operation-gates.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ios-established-app-operation-gates.md diff --git a/.changeset/ios-established-app-operation-gates.md b/.changeset/ios-established-app-operation-gates.md new file mode 100644 index 000000000..fe64db82d --- /dev/null +++ b/.changeset/ios-established-app-operation-gates.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Allow safe native Apple setup in established apps, isolate capability edits from unrelated Associated Domains issues, and let Doctor check independently verified registration when Swift source discovery is incomplete. From f690a5325dbdae3f1db6e27a72b3494cb39075ba Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 17:47:40 -0400 Subject: [PATCH 06/16] refactor(macos): reuse operation inspection during entitlement selection --- .../commands/init/ios/apple-entitlement.ts | 6 +- .../init/ios/entitlements-settings.ts | 2 +- .../src/commands/init/ios/install-sdk.ts | 2 +- .../commands/init/ios/macos-network.test.ts | 154 ++++++++++++++++++ .../src/commands/init/ios/macos-network.ts | 23 ++- 5 files changed, 173 insertions(+), 14 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index be12cec0b..c2f40b154 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -1,3 +1,6 @@ +import { lstat } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { bytesWithOptionalBOM, newEntitlementsBytes, @@ -5,9 +8,6 @@ import { entitlementKeyStructure, decodeEntitlementsXML, } from "./entitlements-xml.ts"; -import { lstat } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; -import { isDeepStrictEqual } from "node:util"; import { selectIOSEntitlementsFiles, type IOSEntitlementsFileBlockerCode, diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts index 47155427c..abb13cf8f 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -1,8 +1,8 @@ -import { generatedProjectKind } from "./project-selection.ts"; import { lstat, readFile, readdir, realpath } from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { generatedProjectKind } from "./project-selection.ts"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; import { diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 03c0b9dcf..b69283e73 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -1,9 +1,9 @@ -import { generatedProjectKind } from "./project-selection.ts"; import { lstat, readFile } from "node:fs/promises"; import { isDeepStrictEqual } from "node:util"; import { dirname, isAbsolute, resolve } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import semver from "semver"; +import { generatedProjectKind } from "./project-selection.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; diff --git a/packages/cli-core/src/commands/init/ios/macos-network.test.ts b/packages/cli-core/src/commands/init/ios/macos-network.test.ts index e723f1488..ea3722751 100644 --- a/packages/cli-core/src/commands/init/ios/macos-network.test.ts +++ b/packages/cli-core/src/commands/init/ios/macos-network.test.ts @@ -113,6 +113,160 @@ afterEach(async () => { ); }); +for (const format of ["pbx", "json"] as const) { + async function networkFixture() { + const root = await mkdtemp(join(tmpdir(), "clerk-network-revalidation-")); + temporaryDirectories.push(root); + if (format === "json") await createIOSJSONFixture(root); + else + await createIOSFixture(root, { + platform: "macos", + macOSAppleEntitlement: false, + secondTarget: true, + }); + if (format === "pbx") { + const path = pbxprojPath(root); + const project = parsePbxProject(await readFile(path, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + // The sibling is iOS; do not let it inherit the primary macOS SDKROOT. + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + (objects[id]!.buildSettings as Record).SDKROOT = "iphoneos"; + } + await Bun.write(path, buildPbxProject(project)); + } + const update = async (key: string, value: string) => { + if (format === "pbx") { + await updateBuildSettings(root, (settings) => { + settings[key] = value; + }); + } else { + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + path, + applyXCProjValue( + await Bun.file(path).text(), + ["targets", 0, "build-settings", key], + value, + ), + ); + } + }; + for (const [key, value] of Object.entries({ + SDKROOT: "macosx", + SUPPORTED_PLATFORMS: "macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + ENABLE_APP_SANDBOX: "YES", + })) { + await update(key, value); + } + await Bun.write( + entitlementsPath(root), + 'com.apple.security.app-sandbox', + ); + const plan = await planMacOSNetworkCapability({ + ...options(root), + targetId: format === "json" ? "C1E000000000000000000001" : IOS_FIXTURE_IDS.appTarget, + }); + expect(plan.status, JSON.stringify(plan.blockers)).toBe("ready"); + return { root, plan, update }; + } + + test(`${format} network preparation rereads settings after planning`, async () => { + const { root, plan, update } = await networkFixture(); + await update("ENABLE_OUTGOING_NETWORK_CONNECTIONS", "NO"); + const before = await treeDigest(root); + const result = await applyMacOSNetworkCapability(plan); + expect(result.status).toBe("blocked"); + expect(result.plan.blockers).toContainEqual( + expect.objectContaining({ code: "conflicting-network-setting" }), + ); + expect(await treeDigest(root)).toEqual(before); + }); + + test(`${format} network preparation rereads generator markers after planning`, async () => { + const { root, plan } = await networkFixture(); + await Bun.write(join(root, "Project.swift"), "import ProjectDescription\n"); + const before = await treeDigest(root); + const result = await applyMacOSNetworkCapability(plan); + expect(result.status).toBe("blocked"); + expect(result.plan.blockers).toContainEqual( + expect.objectContaining({ + code: "unsupported-entitlements", + message: expect.stringContaining("Tuist"), + }), + ); + expect(await treeDigest(root)).toEqual(before); + }); + + test(`${format} network preparation preserves a newer entitlement edit`, async () => { + const { root, plan } = await networkFixture(); + const path = entitlementsPath(root); + await Bun.write( + path, + (await Bun.file(path).text()).replace("", ""), + ); + const before = await treeDigest(root); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("stale"); + expect(await treeDigest(root)).toEqual(before); + }); + + test(`${format} network preparation rechecks newly shared entitlement ownership`, async () => { + const { root, plan } = await networkFixture(); + if (format === "pbx") { + const path = pbxprojPath(root); + const project = parsePbxProject(await readFile(path, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + (objects[id]!.buildSettings as Record).CODE_SIGN_ENTITLEMENTS = + "MyApp/MyApp.entitlements"; + } + await Bun.write(path, buildPbxProject(project)); + } else { + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + path, + applyXCProjValue(await Bun.file(path).text(), ["targets", 1], { + name: "OtherApp", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources", "frameworks"], + "build-settings": { + SDKROOT: "macosx", + SUPPORTED_PLATFORMS: "macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.OtherApp", + CODE_SIGN_ENTITLEMENTS: "MyApp/MyApp.entitlements", + }, + }), + ); + } + const before = await treeDigest(root); + const result = await applyMacOSNetworkCapability(plan); + expect(result.status).toBe("blocked"); + expect(result.plan.blockers).toContainEqual( + expect.objectContaining({ + code: "unsafe-entitlements", + message: expect.stringContaining("shared"), + }), + ); + expect(await treeDigest(root)).toEqual(before); + }); + + test(`${format} network postcondition rereads settings and rolls back`, async () => { + const { root, plan, update } = await networkFixture(); + const prepared = await prepareMacOSNetworkCapabilityMutation(plan); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("Expected network mutation"); + await update("ENABLE_OUTGOING_NETWORK_CONNECTIONS", "NO"); + const before = await treeDigest(root); + const result = await applyIOSFileTransaction(prepared.mutations, [ + () => validatePreparedMacOSNetworkCapability(prepared), + ]); + expect(result.status).toBe("rolled-back"); + expect(await treeDigest(root)).toEqual(before); + }); +} + describe("macOS outgoing network capability", () => { test("does nothing for a provably unsandboxed macOS app", async () => { const root = await temporaryRoot(); diff --git a/packages/cli-core/src/commands/init/ios/macos-network.ts b/packages/cli-core/src/commands/init/ios/macos-network.ts index e6b6ca779..fc4ed43fa 100644 --- a/packages/cli-core/src/commands/init/ios/macos-network.ts +++ b/packages/cli-core/src/commands/init/ios/macos-network.ts @@ -1,3 +1,6 @@ +import { lstat } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { bytesWithOptionalBOM, newEntitlementsBytes, @@ -6,9 +9,6 @@ import { decodeEntitlementsXML, } from "./entitlements-xml.ts"; import { selectedIOSAppTarget as selectedTarget } from "./project-selection.ts"; -import { lstat } from "node:fs/promises"; -import { dirname, isAbsolute, resolve } from "node:path"; -import { isDeepStrictEqual } from "node:util"; import { selectIOSEntitlementsFiles } from "./entitlements-files.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; @@ -655,12 +655,17 @@ export async function planMacOSNetworkCapability( }; } - const ownershipProbe = await selectIOSEntitlementsFiles({ - root: normalized.root, - projectPath: normalized.projectPath, - targetId: normalized.targetId, - platform: "macos", - }); + // Reuse this call's macOS settings view, as the domain planner does. File + // ownership is still read here; preparation and postconditions replan afresh. + const ownershipProbe = await selectIOSEntitlementsFiles( + { + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + platform: "macos", + }, + inspection, + ); if (ownershipProbe.status === "blocked") { return blockedPlan( normalized, From f08ae9db6fccfc1a10b1a887763e2bd3a7e0a773 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 23 Sep 2026 18:10:14 -0400 Subject: [PATCH 07/16] docs(init): explain interactive native target selection --- docs/native-established-apps.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md index 86703395a..1e416519b 100644 --- a/docs/native-established-apps.md +++ b/docs/native-established-apps.md @@ -5,6 +5,14 @@ runtime configuration remains developer-owned. Selecting a Clerk app with `--app` authorizes setup against that app; it does not prove which app a custom runtime publishable key belongs to. +`clerk init` selects a sole application target automatically. With several targets, +an interactive terminal offers a picker showing each target's name, platform, and +project. `--target` bypasses the picker. Agent mode, `--yes`, JSON output, and +redirected input/output require explicit selection when targets are ambiguous; +`--dry-run` can report the available choices without changing anything. Choosing a +target retains every operation's safety checks. Copied projects with colliding +target IDs still require running from the intended project's directory. + Existing `AuthView` is evidence for SDK product and version requirements. When its runtime wiring is unresolved, it does not activate automatic AuthView setup or provider-capability changes. SDK linkage and native registration may still From 6aaa655451e45faf136e52ee32efead133737fc0 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:17:23 -0400 Subject: [PATCH 08/16] fix(init): simplify interactive native Apple setup output --- .../cli-core/src/commands/auth/login.test.ts | 19 ++ packages/cli-core/src/commands/auth/login.ts | 14 +- .../src/commands/init/index-ios.test.ts | 8 +- packages/cli-core/src/commands/init/index.ts | 30 +- .../src/commands/init/ios/apply-cli.test.ts | 2 +- .../cli-core/src/commands/init/ios/apply.ts | 291 ++++++++++-------- .../src/commands/init/ios/coordinator.ts | 3 +- .../src/commands/init/ios/native-apple.ts | 28 +- .../src/commands/init/ios/native-remote.ts | 46 ++- .../commands/init/ios/presentation.test.ts | 128 ++++++++ .../src/commands/init/ios/presentation.ts | 117 +++++++ .../src/commands/init/ios/target-picker.ts | 2 +- .../cli-core/src/commands/link/index.test.ts | 22 ++ packages/cli-core/src/commands/link/index.ts | 22 +- packages/cli-core/src/lib/spinner.test.ts | 22 +- packages/cli-core/src/lib/spinner.ts | 10 +- 16 files changed, 576 insertions(+), 188 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/presentation.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/presentation.ts diff --git a/packages/cli-core/src/commands/auth/login.test.ts b/packages/cli-core/src/commands/auth/login.test.ts index 6cb844544..25fb39978 100644 --- a/packages/cli-core/src/commands/auth/login.test.ts +++ b/packages/cli-core/src/commands/auth/login.test.ts @@ -203,6 +203,25 @@ describe("login", () => { return server; } + test("embedded login preserves the outer setup flow and omits standalone next steps", async () => { + mockIsHuman.mockReturnValue(true); + mockGetAuth.mockResolvedValue(null); + mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" }); + mockOAuthSuccess(); + const spinner = await import("../../lib/spinner.ts"); + const { isInsideGutter } = await import("../../lib/log.ts"); + spinner.intro("Setting up Clerk"); + try { + await login({ embedded: true, showNextSteps: false }); + expect(isInsideGutter()).toBe(true); + expect(captured.err).not.toContain("Signing in"); + expect(captured.err).not.toContain("Next steps"); + expect(mockStoreToken).toHaveBeenCalled(); + } finally { + await spinner.outro(); + } + }); + test("returns early when already authenticated with valid token", async () => { mockGetValidToken.mockResolvedValue("existing-token"); mockGetAuth.mockResolvedValue({ userId: "user_123" }); diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 611f545de..64576f8c9 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -29,6 +29,8 @@ import { currentTelemetryStage, setTelemetryStage } from "../../lib/telemetry.ts import { ensureFirstApplication } from "../../lib/first-application.ts"; interface LoginOptions { + /** Keep the caller’s setup flow open instead of rendering a nested command. */ + embedded?: boolean; showNextSteps?: boolean; yes?: boolean; } @@ -147,7 +149,7 @@ export async function login(options: LoginOptions = {}): Promise { async function runLogin(options: LoginOptions = {}): Promise { const { showNextSteps = true, yes } = options; - intro("Signing in"); + if (!options.embedded) intro("Signing in"); setTelemetryStage("session_check"); const existingSession = await withSpinner("Checking session...", async () => getExistingSession(), @@ -157,9 +159,9 @@ async function runLogin(options: LoginOptions = {}): Promise { setTelemetryStage("done"); log.success(`Logged in as ${existingSession.email}`); const claimResult = await handleAutoclaim(process.cwd()); - if (showNextSteps) { + if (!options.embedded && showNextSteps) { await outro(await loginNextSteps(claimResult)); - } else { + } else if (!options.embedded) { await outro("Done"); } return existingSession; @@ -171,7 +173,7 @@ async function runLogin(options: LoginOptions = {}): Promise { default: false, }); if (!reauthenticate) { - await outro(); + if (!options.embedded) await outro(); throwUserAbort(); } } @@ -207,9 +209,9 @@ async function runLogin(options: LoginOptions = {}): Promise { const claimResult = await handleAutoclaim(process.cwd()); - if (showNextSteps) { + if (!options.embedded && showNextSteps) { await outro(await loginNextSteps(claimResult)); - } else { + } else if (!options.embedded) { await outro("Done"); } diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index bd29317c9..a5f1c3ac6 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -279,8 +279,7 @@ describe("init iOS", () => { framework: expect.objectContaining({ name: "macOS (Swift)" }), }), ); - expect(captured.err).toContain("Detected"); - expect(captured.err).toContain("macOS (Swift)"); + expect(captured.err).not.toContain("Detected"); expect(captured.err).not.toContain("iOS (Swift)"); }); @@ -461,6 +460,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: "AnotherPromptTest", skipAutolink: true, + embedded: true, }); expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( expect.objectContaining({ @@ -515,6 +515,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: undefined, skipAutolink: true, + embedded: true, }); expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( expect.objectContaining({ @@ -546,6 +547,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: undefined, skipAutolink: true, + embedded: true, }); expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); }); @@ -1581,6 +1583,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: undefined, skipAutolink: true, + embedded: true, requireExistingAppSelection: true, }); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); @@ -1628,6 +1631,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: undefined, skipAutolink: true, + embedded: true, requireExistingAppSelection: true, }); expect(resolveKeys).toHaveBeenCalledTimes(1); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index dd2587539..ace883c4e 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -65,6 +65,7 @@ import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; import { validateAppIdPrefix } from "./ios/native-remote.ts"; import { pickAppleNativeTarget } from "./ios/target-picker.ts"; +import { compactNativeOutput, withNativeSpinner } from "./ios/presentation.ts"; import { prepareAppleNativeSetup, runAppleNativeDryRun, @@ -369,7 +370,7 @@ export async function init(options: InitOptions = {}) { } setTelemetryStage("done"); - await outro("Done"); + await outro(ctx.framework.dep === "ios" ? "Automatic setup complete" : "Done"); } /** @@ -597,7 +598,7 @@ async function resolveProjectContext( return bootstrapAndDetect(cwd, frameworkOverride, overrides); } - const ctx = await withSpinner("Detecting framework...", async () => + const ctx = await withNativeSpinner("Inspecting project...", async () => gatherContext(cwd, frameworkOverride, overrides.pmOverride), ); if (ctx) return { ctx, bootstrap: null }; @@ -619,7 +620,7 @@ async function resolveExistingProjectContext( frameworkOverride: FrameworkInfo | undefined, overrides: BootstrapOverrides, ): Promise { - const ctx = await withSpinner("Detecting framework...", async () => + const ctx = await withNativeSpinner("Inspecting project...", async () => gatherContext(cwd, frameworkOverride, overrides.pmOverride), ); if (!ctx) { @@ -638,7 +639,9 @@ async function resolveReadOnlyProjectContext( machineOutput: boolean, ): Promise { const detect = async () => gatherContext(cwd, frameworkOverride, overrides.pmOverride); - const ctx = machineOutput ? await detect() : await withSpinner("Detecting framework...", detect); + const ctx = machineOutput + ? await detect() + : await withNativeSpinner("Inspecting project...", detect); if (!ctx) { throw new CliError( "Could not detect an existing project. Read-only mode never bootstraps or modifies a directory.", @@ -768,14 +771,14 @@ async function runStrategy( // --- Auth --- -async function resolveAuthLabel(): Promise { +async function resolveAuthLabel(embedded = false): Promise { const hasApiKey = Boolean(process.env.CLERK_PLATFORM_API_KEY); if (hasApiKey) return "Using API key"; const email = await getAuthenticatedEmail(); if (email) return `Logged in as ${email}`; - await login({ showNextSteps: false }); + await login({ showNextSteps: false, ...(embedded && { embedded: true }) }); return ""; } @@ -790,7 +793,7 @@ async function authenticateAndLink( applicationId?: string; applicationLinkChange?: "created-and-linked" | "link-updated"; }> { - const label = preauthenticatedLabel ?? (await resolveAuthLabel()); + const label = preauthenticatedLabel ?? (await resolveAuthLabel(requireLinkedAppId)); const profile = await resolveProfile(cwd); const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app); @@ -809,7 +812,7 @@ async function authenticateAndLink( app, cwd, createIfMissing, - ...(requireLinkedAppId && { skipAutolink: true }), + ...(requireLinkedAppId && { skipAutolink: true, embedded: true }), ...(requireExplicitApplication && { requireExistingAppSelection: true }), }); @@ -927,7 +930,9 @@ async function detectAndInstall( skipConfirm: boolean, ): Promise<{ alreadySetUp: boolean }> { const variantLabel = ctx.variant ? ` (${ctx.variant})` : ""; - log.info(`\nDetected ${bold(ctx.framework.name)}${variantLabel}`); + if (ctx.framework.dep !== "ios" || !compactNativeOutput()) { + log.info(`\nDetected ${bold(ctx.framework.name)}${variantLabel}`); + } detectAuthLibraries(ctx.deps); log.blank(); @@ -960,7 +965,12 @@ async function scaffoldAndWrite( } if (!hasChanges) { - log.info(dim("\nNo files to scaffold, but:")); + if (ctx.framework.dep === "ios" && compactNativeOutput()) { + log.info("\nNext steps:"); + log.info(" • Open your Xcode project, build, and test sign-in."); + } else { + log.info(dim("\nNo files to scaffold, but:")); + } for (const instr of plan.postInstructions) { log.info(dim(` • ${instr}`)); } diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 44b0ecdfe..fbb12de89 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -969,7 +969,7 @@ struct MyApp: App { 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 === "Add Clerk’s prebuilt sign-in screen?") 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}`); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 80a3e10dd..0a310fcc1 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -10,7 +10,11 @@ import { } from "../../../lib/errors.ts"; import { log } from "../../../lib/log.ts"; import { confirm } from "../../../lib/prompts.ts"; -import { withSpinner } from "../../../lib/spinner.ts"; +import { + withNativeSpinner as withSpinner, + compactNativeOutput, + printNativeLocalPreview, +} from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { prepareIOSSDKInstallMutation, @@ -324,6 +328,13 @@ export async function applyIOSLocalSetup( ); } const platformLabel = selectedTarget.platform === "macos" ? "macOS" : "iOS"; + if (compactNativeOutput()) { + const platforms = selectedTarget.supportedPlatforms + .map((p) => (p === "ios" ? "iOS" : "macOS")) + .join(" + "); + log.success(`Found ${platforms} app: ${selection.targetName}`); + log.info(dim(`Project: ${selection.projectPath}`)); + } const proposal = await buildIOSLocalSetupProposal(context, { root: options.root, @@ -332,14 +343,14 @@ export async function applyIOSLocalSetup( signInWithApple: options.signInWithApple, ...(!options.agent && !options.yes ? { - resolvePrebuiltAuthRequest: async ({ targetName }: { targetName: string }) => + resolvePrebuiltAuthRequest: async () => confirm({ - message: `Add ClerkKitUI's prebuilt authentication UI to ${targetName}?`, + message: "Add Clerk’s prebuilt sign-in screen?", default: false, }), - resolveNativeAppleRequest: async ({ bundleIdentifier }: { bundleIdentifier: string }) => + resolveNativeAppleRequest: async () => confirm({ - message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + message: "Enable native Sign in with Apple?", default: false, }), } @@ -598,164 +609,174 @@ export async function applyIOSLocalSetup( macOSNetworkCapabilityPlan?.status === "ready" || appleEntitlementPlan?.status === "ready" || prebuiltAuthAppleEntitlementPlan?.status === "ready"; - if (hasLocalWrites) { - log.info(`\nclerk init will make the following local ${platformLabel} changes:\n`); - } else if ( - directConfigPlan || - macOSNetworkCapabilityPlan || - appleEntitlementPlan || - prebuiltAuthPlan - ) { - log.info(`\nclerk init will perform the following read-only ${platformLabel} verification:\n`); - } - if (installPlan.status === "ready") { - log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); - for (const action of installPlan.actions) log.info(` ${action}`); - } - if (directConfigPlan) { - const operation = directConfigNeedsWrite(directConfigPlan) ? "MODIFY" : "VERIFY"; - log.info(` ${yellow(operation)} ${directConfigPlan.sourcePath}`); - for (const action of directConfigPlan.actions) log.info(` ${action}`); - log.info( - dim( - " The linked development publishable key will remain in memory and is redacted from the preview and command output.", - ), - ); - } - if (hasCustomConfigure) { - log.info( - dim( - " PRESERVE Custom Clerk.configure(...) publishable-key source. Its value will not be inspected; the developer must select the existing Clerk application it belongs to.", - ), - ); - if (!hasSupportedCustomConfigure) { - log.warn( - "Custom Clerk configuration: startup execution and runtime key match remain unverified. SDK linkage and native registration can proceed for the explicitly selected Clerk app; verify runtime initialization and the custom key's application manually.", + if (compactNativeOutput()) { + printNativeLocalPreview(proposal, projectDocumentDisplayPath); + } else { + if (hasLocalWrites) { + log.info(`\nclerk init will make the following local ${platformLabel} changes:\n`); + } else if ( + directConfigPlan || + macOSNetworkCapabilityPlan || + appleEntitlementPlan || + prebuiltAuthPlan + ) { + log.info( + `\nclerk init will perform the following read-only ${platformLabel} verification:\n`, ); } - } - 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") { + if (installPlan.status === "ready") { log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); + for (const action of installPlan.actions) log.info(` ${action}`); } - 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) { + 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 exact linked development host will be resolved after authentication and is redacted from this preview.", + " The linked development publishable key will remain in memory and is redacted from the preview and command output.", ), ); } - } - if (macOSNetworkCapabilityPlan?.status === "ready") { - if ( - macOSNetworkCapabilityPlan.missingEntitlementsSettings && - installPlan.status !== "ready" && - !associatedDomainPlan?.missingEntitlementsSettings - ) { - log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); - } - for (const file of macOSNetworkCapabilityPlan.files) { - log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); - } - for (const action of macOSNetworkCapabilityPlan.actions) log.info(` ${action}`); - } else if (macOSNetworkCapabilityPlan?.status === "satisfied") { - log.info(dim("\n Outgoing network access is already available to the selected macOS target.")); - } - if (appleEntitlementPlan?.status === "ready") { - const alreadyPreviewedEntitlements = new Set([ - ...(associatedDomainNeedsWrite(associatedDomainPlan) - ? associatedDomainPlan.files.map((file) => file.path) - : []), - ...(macOSNetworkCapabilityPlan?.status === "ready" - ? macOSNetworkCapabilityPlan.files.map((file) => file.path) - : []), - ]); - if ( - appleEntitlementPlan.missingEntitlementsSettings && - installPlan.status !== "ready" && - !associatedDomainPlan?.missingEntitlementsSettings && - !macOSNetworkCapabilityPlan?.missingEntitlementsSettings - ) { - log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); + if (prebuiltAuthPlan) { + const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${prebuiltAuthPlan.sourcePath}`); + for (const action of prebuiltAuthPlan.actions) log.info(` ${action}`); } - for (const file of appleEntitlementPlan.files) { - if (!alreadyPreviewedEntitlements.has(file.path)) { + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings && installPlan.status !== "ready") { + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); + } + for (const file of associatedDomainPlan.files) { 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(projectDocumentDisplayPath); - } - if (associatedDomainNeedsWrite(associatedDomainPlan)) { - if (associatedDomainPlan.missingEntitlementsSettings) { - alreadyPreviewedPaths.add(projectDocumentDisplayPath); + 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.", + ), + ); } - for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); } if (macOSNetworkCapabilityPlan?.status === "ready") { - if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { - alreadyPreviewedPaths.add(projectDocumentDisplayPath); + if ( + macOSNetworkCapabilityPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); } for (const file of macOSNetworkCapabilityPlan.files) { - alreadyPreviewedPaths.add(file.path); + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); } + for (const action of macOSNetworkCapabilityPlan.actions) log.info(` ${action}`); + } else if (macOSNetworkCapabilityPlan?.status === "satisfied") { + log.info( + dim("\n Outgoing network access is already available to the selected macOS target."), + ); } - if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { - const projectFile = projectDocumentDisplayPath; - if (!alreadyPreviewedPaths.has(projectFile)) { - log.info(` ${yellow("MODIFY")} ${projectFile}`); + if (appleEntitlementPlan?.status === "ready") { + const alreadyPreviewedEntitlements = new Set([ + ...(associatedDomainNeedsWrite(associatedDomainPlan) + ? associatedDomainPlan.files.map((file) => file.path) + : []), + ...(macOSNetworkCapabilityPlan?.status === "ready" + ? macOSNetworkCapabilityPlan.files.map((file) => file.path) + : []), + ]); + if ( + appleEntitlementPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings && + !macOSNetworkCapabilityPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); + } + 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."), + ); } - for (const file of prebuiltAuthAppleEntitlementPlan.files) { - if (!alreadyPreviewedPaths.has(file.path)) { - log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + 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(projectDocumentDisplayPath); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(projectDocumentDisplayPath); + } + for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); + } + if (macOSNetworkCapabilityPlan?.status === "ready") { + if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(projectDocumentDisplayPath); + } + for (const file of macOSNetworkCapabilityPlan.files) { + alreadyPreviewedPaths.add(file.path); + } + } + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + const projectFile = projectDocumentDisplayPath; + 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}`); } } - 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.")); } - } - 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.", + nativeAppleRequested + ? `\n After authentication, clerk init will inspect Native API, ${platformLabel} registration, and the native Apple connection before separately previewing additive remote changes.` + : `\n After authentication, clerk init will inspect Native API and ${platformLabel} registration state and separately preview any additive remote changes.`, ), ); + log.blank(); } - if (installPlan.status === "ready") { - log.info(dim("\n Package resolution and xcodebuild will not run.")); + if (hasCustomConfigure) { + log.info( + dim( + " PRESERVE Custom Clerk.configure(...) publishable-key source. Its value will not be inspected; the developer must select the existing Clerk application it belongs to.", + ), + ); + if (!hasSupportedCustomConfigure) { + log.warn( + "Custom Clerk configuration: startup execution and runtime key match remain unverified. SDK linkage and native registration can proceed for the explicitly selected Clerk app; verify runtime initialization and the custom key's application manually.", + ); + } } - log.info( - dim( - nativeAppleRequested - ? `\n After authentication, clerk init will inspect Native API, ${platformLabel} registration, and the native Apple connection before separately previewing additive remote changes.` - : `\n After authentication, clerk init will inspect Native API and ${platformLabel} registration state and separately preview any additive remote changes.`, - ), - ); - log.blank(); if (hasLocalWrites && options.agent && !options.yes) { throwUsageError( @@ -764,7 +785,9 @@ export async function applyIOSLocalSetup( } if (hasLocalWrites && !options.yes) { const proceed = await confirm({ - message: `Apply these local ${platformLabel} changes?`, + message: compactNativeOutput() + ? "Continue with these local changes?" + : `Apply these local ${platformLabel} changes?`, default: false, }); if (!proceed) throwUserAbort(); diff --git a/packages/cli-core/src/commands/init/ios/coordinator.ts b/packages/cli-core/src/commands/init/ios/coordinator.ts index bdd542446..ce10f097b 100644 --- a/packages/cli-core/src/commands/init/ios/coordinator.ts +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -3,7 +3,8 @@ import { resolveProfile } from "../../../lib/config.js"; import { decodePublishableKey, fetchUserSettings } from "../../../lib/fapi.ts"; import { log } from "../../../lib/log.js"; import { interruptedExitCode } from "../../../lib/signals.ts"; -import { outro, withSpinner } from "../../../lib/spinner.js"; +import { outro } from "../../../lib/spinner.js"; +import { withNativeSpinner as withSpinner } from "./presentation.ts"; import { setTelemetryStage, type TelemetryStage } from "../../../lib/telemetry.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; import { diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts index 7fe1c7694..96f0f8013 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -17,7 +17,7 @@ import { type InstanceConfigSchema, } from "../../../lib/plapi.ts"; import { confirm } from "../../../lib/prompts.ts"; -import { withSpinner } from "../../../lib/spinner.ts"; +import { withNativeSpinner as withSpinner, compactNativeOutput } from "./presentation.ts"; import type { IOSNativePlatform } from "./types.ts"; const APPLE_CONNECTION_KEY = "connection_oauth_apple"; @@ -186,7 +186,9 @@ const defaultPrompts: IOSNativeApplePrompts = { }), confirmChanges: async () => confirm({ - message: "Apply this remote Clerk Sign in with Apple change?", + message: compactNativeOutput() + ? "Enable Sign in with Apple in Clerk?" + : "Apply this remote Clerk Sign in with Apple change?", default: false, }), }; @@ -795,14 +797,20 @@ export async function prepareIOSNativeAppleConnection( 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 (compactNativeOutput()) { + log.info(`\nEnable native Sign in with Apple for ${plan.bundleIdentifier} in Clerk.`); + log.info(dim("Existing web sign-in settings will be preserved.")); + log.blank(); + } else { + 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( diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts index b45a46611..ca0951e38 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -23,7 +23,7 @@ import { type NativeSettings, } from "../../../lib/plapi.ts"; import { confirm, text } from "../../../lib/prompts.ts"; -import { withSpinner } from "../../../lib/spinner.ts"; +import { withNativeSpinner as withSpinner, compactNativeOutput } from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import type { IOSNativeReadinessTarget, @@ -197,6 +197,14 @@ export interface IOSNativeRemotePrompts { const defaultPrompts: IOSNativeRemotePrompts = { appIdPrefix: async (bundleIdentifier, suggested) => { + log.info( + "Use the App ID Prefix from Apple Developer → Certificates, Identifiers & Profiles → Identifiers.", + ); + log.info( + dim( + "It usually matches your Team ID. Verify it at https://developer.apple.com/account/resources/identifiers/list", + ), + ); if (suggested?.source === "xcode-development-team") { const choice = await select({ message: `Apple App ID Prefix for ${bundleIdentifier}`, @@ -220,14 +228,19 @@ const defaultPrompts: IOSNativeRemotePrompts = { return text({ message: `Apple App ID Prefix for ${bundleIdentifier}`, default: suggested?.source === "partial-literal-entitlements" ? suggested.value : undefined, - placeholder: suggested?.value ?? "ABCDE12345", + placeholder: suggested?.value ?? "Example: ABCDE12345", validate: (value) => validateAppIdPrefix(value) != null || `Enter an App ID Prefix containing exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers. Verify it in Apple Developer; it can differ from your Team ID.`, }); }, confirmChanges: async () => - confirm({ message: "Apply these remote Clerk Native Application changes?", default: false }), + confirm({ + message: compactNativeOutput() + ? "Apply these Clerk registration changes?" + : "Apply these remote Clerk Native Application changes?", + default: false, + }), }; function blocker(code: IOSNativeRemoteBlockerCode, message: string): IOSNativeRemoteBlocker { @@ -731,14 +744,25 @@ export async function prepareIOSNativeRemoteSetup( 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 Apple native application registration.", - ), - ); - log.blank(); + if (compactNativeOutput()) { + log.info("\nClerk registration changes:"); + log.info(` Bundle ID: ${plan.bundleIdentifier}`); + log.info(` App ID Prefix: ${plan.appIdPrefix}`); + if (plan.registration === "required") + log.info(` Register this ${platformName(plan.platform)} app`); + if (plan.nativeApi === "required") + log.info(" Enable the Native API for this development instance"); + log.blank(); + } else { + 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 Apple native application registration.", + ), + ); + log.blank(); + } if (options.agent && !options.yes) { throwUsageError( diff --git a/packages/cli-core/src/commands/init/ios/presentation.test.ts b/packages/cli-core/src/commands/init/ios/presentation.test.ts new file mode 100644 index 000000000..91ef60572 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/presentation.test.ts @@ -0,0 +1,128 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { getMode, setMode } from "../../../mode.ts"; +import { getLogLevel, setLogLevel } from "../../../lib/log.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { applyIOSLocalSetup } from "./apply.ts"; +import { treeDigest } from "./test-helpers.ts"; +import { + createUnconfiguredFixture, + canonicalSwiftUIFixture, + addStarterContentViewToFixture, + createIsolatedCLIState, + cleanupApplyCLITestState, + currentNativeRemoteState, + resetApplyCLITestRemoteState, + runCLI, +} from "./apply-cli.test-helpers.ts"; + +const captured = useCaptureLog(); +afterEach(cleanupApplyCLITestState); + +test("compact preview preserves the verbose plan, conditional file consent, and read-only preparation", async () => { + const root = await createUnconfiguredFixture(); + await addStarterContentViewToFixture(root); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + await Bun.file(join(canonicalSwiftUIFixture, "MyApp", "MyAppApp.swift")).text(), + ); + const before = await treeDigest(root); + const mode = getMode(); + const level = getLogLevel(); + try { + setMode("human"); + setLogLevel("info"); + const options = { + root, + target: "MyApp", + agent: false, + yes: true, + allowDirty: true, + prebuiltAuthUI: true, + signInWithApple: false, + }; + const compact = await applyIOSLocalSetup(options); + expect(captured.err).toContain("Local changes:"); + expect(captured.err).toContain("Replace the starter screen"); + expect(captured.err).toContain("only if Apple sign-in is enabled"); + expect(captured.err).not.toContain("Frameworks phase"); + expect(captured.err).not.toContain("remain in memory"); + // Every possible capability file is still presented before approval. + for (const file of compact.prebuiltAuthAppleEntitlementPlan?.files ?? []) { + expect(captured.err).toContain(file.path); + } + captured.clear(); + setLogLevel("debug"); + const verbose = await applyIOSLocalSetup(options); + expect(captured.err).toContain("Frameworks phase"); + expect(verbose).toEqual(compact); + expect(await treeDigest(root)).toEqual(before); + } finally { + setMode(mode); + setLogLevel(level); + } +}); + +test("declining the concise local preview still leaves every file unchanged", async () => { + const root = await createUnconfiguredFixture(); + const before = await treeDigest(root); + const mode = getMode(); + setMode("human"); + const confirm = spyOn(prompts, "confirm").mockResolvedValue(false); + try { + await expect( + applyIOSLocalSetup({ + root, + agent: false, + yes: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }), + ).rejects.toThrow(); + expect(confirm).toHaveBeenCalledWith({ + message: "Continue with these local changes?", + default: false, + }); + expect(await treeDigest(root)).toEqual(before); + } finally { + confirm.mockRestore(); + setMode(mode); + } +}); + +test("human init completes local and remote setup without nested completion messages", async () => { + resetApplyCLITestRemoteState(); + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const result = await runCLI( + root, + [ + "--mode", + "human", + "init", + "--yes", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + "--sign-in-with-apple", + ], + configDir, + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr).toContain("Clerk registration changes:"); + expect(result.stderr).toContain("Enable the Native API"); + expect(result.stderr).toContain("Existing web sign-in settings will be preserved"); + expect(result.stderr).toContain("Automatic setup complete"); + expect(result.stderr).not.toContain("No files to scaffold"); + expect(result.stderr).not.toContain("clerk env pull"); + expect(result.stderr).not.toContain("Linking project"); + expect(currentNativeRemoteState().mutations).toEqual({ + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 2, // Provider validation dry run, followed by the actual patch. + }); + expect(await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text()).toContain("Clerk.configure"); +}); diff --git a/packages/cli-core/src/commands/init/ios/presentation.ts b/packages/cli-core/src/commands/init/ios/presentation.ts new file mode 100644 index 000000000..674ad6f79 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/presentation.ts @@ -0,0 +1,117 @@ +import { isHuman } from "../../../mode.ts"; +import { dim, yellow } from "../../../lib/color.ts"; +import { getLogLevel, log } from "../../../lib/log.ts"; +import { withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; +import type { IOSLocalSetupProposal } from "./local-plan.ts"; + +export function compactNativeOutput(): boolean { + return isHuman() && getLogLevel() !== "debug"; +} + +/** Routine checks stay visible while running; verbose mode retains their history. */ +export async function withNativeSpinner( + message: string, + fn: (controls: SpinnerControls) => Promise, +): Promise { + return withSpinner(message, fn, compactNativeOutput() ? null : undefined); +} + +/** Summarize the approved plans without changing their actions or prerequisites. */ +export function printNativeLocalPreview( + proposal: IOSLocalSetupProposal, + projectFile: string, +): void { + const rows = new Map }>(); + const add = (path: string, operation: string, detail: string) => { + const row = rows.get(path) ?? { operation, details: new Set() }; + if (operation === "CREATE") row.operation = operation; + row.details.add(detail); + rows.set(path, row); + }; + const sdk = proposal.installPlan; + if (sdk?.status === "ready") { + add(projectFile, "UPDATE", `Link ${sdk.products.join(" and ")}`); + } + const direct = proposal.directConfigPlan; + if (direct?.sourcePath && direct.changes) { + const changes = direct.changes; + const writes = + changes.clerkKitImport === "insert" || + changes.configuration !== "verify-existing" || + changes.environment === "insert"; + const operation = writes ? "UPDATE" : "VERIFY"; + add( + direct.sourcePath, + operation, + changes.configuration === "verify-existing" + ? "Verify the existing configuration matches the linked development app" + : "Configure Clerk with your development publishable key", + ); + if (changes.environment === "insert") { + add(direct.sourcePath, operation, "Make Clerk available to your SwiftUI views"); + } + } + const ui = proposal.prebuiltAuthPlan; + if (ui?.sourcePath) { + add( + ui.sourcePath, + ui.status === "ready" ? "UPDATE" : "VERIFY", + ui.status === "ready" + ? "Replace the starter screen with sign-in and account UI" + : "Keep the existing Clerk sign-in UI", + ); + } + const capability = ( + plan: + | IOSLocalSetupProposal["appleEntitlementPlan"] + | IOSLocalSetupProposal["associatedDomainPlan"] + | IOSLocalSetupProposal["macOSNetworkCapabilityPlan"], + description: string, + conditional = false, + ) => { + if (plan?.status !== "ready") return; + if (plan.missingEntitlementsSettings) { + add( + projectFile, + "UPDATE", + conditional + ? "Attach entitlements if Apple sign-in is enabled for the linked app" + : "Attach the selected target’s entitlements", + ); + } + for (const file of plan.files) { + add(file.path, file.operation === "create" ? "CREATE" : "UPDATE", description); + } + }; + capability(proposal.associatedDomainPlan, "Add Clerk’s Associated Domain"); + capability(proposal.macOSNetworkCapabilityPlan, "Allow outgoing network connections"); + capability(proposal.appleEntitlementPlan, "Add the Sign in with Apple entitlement"); + if (proposal.prebuiltAuthAppleEntitlementPlan !== proposal.appleEntitlementPlan) { + capability( + proposal.prebuiltAuthAppleEntitlementPlan, + "Add the Apple entitlement only if Apple sign-in is enabled for the linked app", + true, + ); + } + if (rows.size > 0) { + const target = proposal.nativeReadiness.target; + if (target.status === "selected" && target.bundleIdentifier.status === "resolved") { + log.info(dim(`Bundle ID: ${target.bundleIdentifier.value}`)); + } + log.info( + [...rows.values()].some((row) => row.operation !== "VERIFY") + ? "\nLocal changes:\n" + : "\nLocal checks:\n", + ); + for (const [path, row] of rows) { + log.info(` ${yellow(row.operation)} ${path}`); + for (const detail of row.details) log.info(` ${detail}`); + } + } else { + log.info("No local file changes planned."); + } + log.info( + dim("\nClerk settings will be reviewed separately. Use --verbose for implementation details."), + ); + log.blank(); +} diff --git a/packages/cli-core/src/commands/init/ios/target-picker.ts b/packages/cli-core/src/commands/init/ios/target-picker.ts index 969a07708..6245192de 100644 --- a/packages/cli-core/src/commands/init/ios/target-picker.ts +++ b/packages/cli-core/src/commands/init/ios/target-picker.ts @@ -1,5 +1,5 @@ import { select } from "../../../lib/listage.ts"; -import { withSpinner } from "../../../lib/spinner.ts"; +import { withNativeSpinner as withSpinner } from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; /** Choose a target using the same selector and discovery rules as explicit --target. */ diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 4f8f1ba45..5fdef01b7 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -157,6 +157,28 @@ describe("link", () => { return link(options); } + test("embedded linking keeps the outer flow open and omits env-pull instructions", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue(undefined); + mockFetchApplication.mockResolvedValue(mockApp); + const spinner = await import("../../lib/spinner.ts"); + const introSpy = spyOn(spinner, "intro"); + const outroSpy = spyOn(spinner, "outro"); + try { + await link({ app: "app_123", embedded: true }); + expect(introSpy).not.toHaveBeenCalled(); + expect(outroSpy).not.toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalled(); + expect(captured.err).not.toContain("Linking project"); + expect(captured.err).not.toContain("clerk env pull"); + expect(captured.err).toContain("Linked to"); + } finally { + introSpy.mockRestore(); + outroSpy.mockRestore(); + } + }); + describe("agent mode", () => { test("links directly with --app", async () => { mockIsAgent.mockReturnValue(true); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 03644ca89..19c903721 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -16,6 +16,8 @@ import { intro, outro } from "../../lib/spinner.ts"; import { log } from "../../lib/log.ts"; interface LinkOptions { + /** Called within another command that owns its header and next steps. */ + embedded?: boolean; app?: string; skipIfLinked?: boolean; cwd?: string; @@ -72,23 +74,23 @@ export async function link(options: LinkOptions = {}): Promise { ); } - intro("Linking project"); + if (!options.embedded) intro("Linking project"); if (existing && agent) { printExistingStatus(existing, normalizedRemote); if (!targetsDifferentApp) { - await outro(); + if (!options.embedded) await outro(); return; } } else if (existing) { const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); if (!shouldRelink) { - await outro(); + if (!options.embedded) await outro(); return; } } - await ensureAuth(); + await ensureAuth(options.embedded); const app = options.app ? await withApiContext( @@ -127,12 +129,16 @@ export async function link(options: LinkOptions = {}): Promise { }); const label = app.name || app.application_id; - log.success(`Linked to ${cyan(label)} in ${dim(displayPath)}`); + log.success( + options.embedded + ? `Linked to ${cyan(label)}` + : `Linked to ${cyan(label)} in ${dim(displayPath)}`, + ); - await outro(NEXT_STEPS.LINK); + if (!options.embedded) await outro(NEXT_STEPS.LINK); } -async function ensureAuth() { +async function ensureAuth(embedded?: boolean) { // CLERK_PLATFORM_API_KEY is a valid non-interactive auth mechanism. // The PLAPI fetch helpers use it directly for API calls, so no OAuth // token is needed when this key is present. @@ -140,7 +146,7 @@ async function ensureAuth() { const token = await getToken(); if (!token) { log.info("Not logged in. Authenticating first..."); - await login({ showNextSteps: false }); + await login({ showNextSteps: false, ...(embedded && { embedded: true }) }); } } diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index d89e01bbd..df86fe755 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -9,7 +9,7 @@ let lastOutroLabel: string | undefined; let outroCalls = 0; interface SpinnerCall { - type: "start" | "stop" | "error" | "message"; + type: "start" | "stop" | "error" | "message" | "clear"; message?: string; } let spinnerCalls: SpinnerCall[] = []; @@ -33,6 +33,9 @@ mock.module("@clack/prompts", () => ({ message: (message?: string) => { spinnerCalls.push({ type: "message", message }); }, + clear: () => { + spinnerCalls.push({ type: "clear" }); + }, error: (message?: string) => { spinnerCalls.push({ type: "error", message }); }, @@ -265,3 +268,20 @@ test("withSpinner calls error() on the spinner and rethrows when fn throws", asy expect(types).toEqual(["start", "error"]); expect(spinnerCalls[1]?.message).toBe("Failed"); }); + +test("transient progress clears on success but retains failures", async () => { + expect(await withSpinner("Checking...", async () => 42, null)).toBe(42); + expect(spinnerCalls.map((c) => c.type)).toEqual(["start", "clear"]); + spinnerCalls = []; + const error = new Error("check failed"); + await expect( + withSpinner( + "Checking...", + async () => { + throw error; + }, + null, + ), + ).rejects.toBe(error); + expect(spinnerCalls.map((c) => c.type)).toEqual(["start", "error"]); +}); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 2222cb838..70e1ef278 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -131,15 +131,19 @@ export async function withGutter( export async function withSpinner( message: string, fn: (controls: SpinnerControls) => Promise, - doneMessage?: string, + doneMessage?: string | null, ): Promise { if (!isHuman()) return fn({ update: () => {} }); - const s = clackSpinner({ output: getOutput() }); + const s = clackSpinner({ + output: getOutput(), + ...(doneMessage === null && { withGuide: false }), + }); s.start(message); try { const result = await fn({ update: (nextMessage) => s.message(nextMessage) }); - s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); + if (doneMessage === null) s.clear(); + else s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { // An interrupt aborts whatever the spinner was waiting on, so the rejection From 67d39bb5739866ff975cfbe4b46689fc0cc8255a Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:17:39 -0400 Subject: [PATCH 09/16] docs(changeset): summarize native Apple setup improvements --- .changeset/ios-established-app-operation-gates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ios-established-app-operation-gates.md b/.changeset/ios-established-app-operation-gates.md index fe64db82d..0058b4f6a 100644 --- a/.changeset/ios-established-app-operation-gates.md +++ b/.changeset/ios-established-app-operation-gates.md @@ -2,4 +2,4 @@ "clerk": patch --- -Allow safe native Apple setup in established apps, isolate capability edits from unrelated Associated Domains issues, and let Doctor check independently verified registration when Swift source discovery is incomplete. +Improve native Apple setup with clearer previews and progress, safe independent operations in established apps, capability edits isolated from unrelated Associated Domains issues, and Doctor registration checks when Swift source discovery is incomplete. From b5c5c0cb57d4ea785b0485e886b165aee2a175ed Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:31:44 -0400 Subject: [PATCH 10/16] fix(init): avoid flashing progress for quick native checks --- .../src/commands/init/ios/presentation.ts | 2 +- packages/cli-core/src/lib/spinner.test.ts | 65 +++++++++++++++++-- packages/cli-core/src/lib/spinner.ts | 37 ++++++++--- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/presentation.ts b/packages/cli-core/src/commands/init/ios/presentation.ts index 674ad6f79..01557086a 100644 --- a/packages/cli-core/src/commands/init/ios/presentation.ts +++ b/packages/cli-core/src/commands/init/ios/presentation.ts @@ -8,7 +8,7 @@ export function compactNativeOutput(): boolean { return isHuman() && getLogLevel() !== "debug"; } -/** Routine checks stay visible while running; verbose mode retains their history. */ +/** Show progress for slower checks; verbose mode retains every check's history. */ export async function withNativeSpinner( message: string, fn: (controls: SpinnerControls) => Promise, diff --git a/packages/cli-core/src/lib/spinner.test.ts b/packages/cli-core/src/lib/spinner.test.ts index df86fe755..fcc8b4f44 100644 --- a/packages/cli-core/src/lib/spinner.test.ts +++ b/packages/cli-core/src/lib/spinner.test.ts @@ -269,19 +269,72 @@ test("withSpinner calls error() on the spinner and rethrows when fn throws", asy expect(spinnerCalls[1]?.message).toBe("Failed"); }); -test("transient progress clears on success but retains failures", async () => { - expect(await withSpinner("Checking...", async () => 42, null)).toBe(42); - expect(spinnerCalls.map((c) => c.type)).toEqual(["start", "clear"]); - spinnerCalls = []; - const error = new Error("check failed"); +test.each([ + { outcome: "success", error: undefined }, + { outcome: "failure", error: new Error("check failed") }, + { outcome: "cancellation", error: new UserAbortError() }, +])( + "fast transient checks settle without flashing or leaving a pending spinner on $outcome", + async ({ error }) => { + const result = withSpinner( + "Checking...", + async ({ update }) => { + update("Checking another detail..."); + if (error) throw error; + return 42; + }, + null, + ); + if (error) await expect(result).rejects.toBe(error); + else expect(await result).toBe(42); + expect(spinnerCalls).toEqual([]); + await Bun.sleep(300); + expect(spinnerCalls).toEqual([]); + }, +); + +test("slower transient checks show the latest message, accept updates, and clear on completion", async () => { + const result = await withSpinner( + "Checking...", + async ({ update }) => { + update("Waiting for the server..."); + expect(spinnerCalls).toEqual([]); + await Bun.sleep(300); + expect(spinnerCalls).toEqual([{ type: "start", message: "Waiting for the server..." }]); + update("Reading the response..."); + return 42; + }, + null, + ); + expect(result).toBe(42); + expect(spinnerCalls).toEqual([ + { type: "start", message: "Waiting for the server..." }, + { type: "message", message: "Reading the response..." }, + { type: "clear" }, + ]); +}); + +test.each([ + { + outcome: "failure", + error: new Error("check failed"), + completion: { type: "error", message: "Failed" }, + }, + { + outcome: "cancellation", + error: new UserAbortError(), + completion: { type: "stop", message: "Checking" }, + }, +])("visible transient checks clean up and propagate $outcome", async ({ error, completion }) => { await expect( withSpinner( "Checking...", async () => { + await Bun.sleep(300); throw error; }, null, ), ).rejects.toBe(error); - expect(spinnerCalls.map((c) => c.type)).toEqual(["start", "error"]); + expect(spinnerCalls).toEqual([{ type: "start", message: "Checking..." }, completion]); }); diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index 70e1ef278..be929d9be 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -10,6 +10,7 @@ import { getUiOutput } from "./ui.ts"; const S_BAR = "│"; const S_BAR_END = "└"; const PAUSED_INSTRUCTION = "Run this command again to continue."; +const TRANSIENT_SPINNER_DELAY_MS = 250; const logUiOutput = new Writable({ write(chunk, _encoding, callback) { @@ -135,22 +136,38 @@ export async function withSpinner( ): Promise { if (!isHuman()) return fn({ update: () => {} }); - const s = clackSpinner({ - output: getOutput(), - ...(doneMessage === null && { withGuide: false }), - }); - s.start(message); + const transient = doneMessage === null; + let s: ReturnType | undefined; + let currentMessage = message; + const start = () => { + s = clackSpinner({ + output: getOutput(), + ...(transient && { withGuide: false }), + }); + s.start(currentMessage); + }; + // Fast checks should never touch the terminal. Delay only the indicator, + // not the work or the next prompt; verbose progress still starts immediately. + const timer = transient ? setTimeout(start, TRANSIENT_SPINNER_DELAY_MS) : undefined; + if (!transient) start(); try { - const result = await fn({ update: (nextMessage) => s.message(nextMessage) }); - if (doneMessage === null) s.clear(); - else s.stop(doneMessage ?? message.replace(/\.{3}$/, "")); + const result = await fn({ + update: (nextMessage) => { + currentMessage = nextMessage; + s?.message(nextMessage); + }, + }); + if (transient) s?.clear(); + else s?.stop(doneMessage ?? message.replace(/\.{3}$/, "")); return result; } catch (error) { // An interrupt aborts whatever the spinner was waiting on, so the rejection // arrives here first. Rendering "Failed" for a cancel the user asked for is // wrong, and it prints before the SIGINT handler finishes exiting. - if (isCancelled(error)) s.stop(message.replace(/\.{3}$/, "")); - else s.error("Failed"); + if (isCancelled(error)) s?.stop(message.replace(/\.{3}$/, "")); + else s?.error("Failed"); throw error; + } finally { + clearTimeout(timer); } } From 216f87bc4629a9bb0a5a0c7f0e6d0116e68b2252 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:43:14 -0400 Subject: [PATCH 11/16] fix(init): group native setup progress into steady phases --- packages/cli-core/src/commands/init/index.ts | 15 ++- .../init/ios/apply-cli.test-helpers.ts | 7 + .../cli-core/src/commands/init/ios/apply.ts | 4 + .../src/commands/init/ios/coordinator.ts | 11 +- .../src/commands/init/ios/native-apple.ts | 9 +- .../src/commands/init/ios/native-remote.ts | 20 ++- .../commands/init/ios/presentation.test.ts | 31 +++++ .../src/commands/init/ios/presentation.ts | 44 +++++- .../src/commands/init/ios/progress.test.ts | 125 ++++++++++++++++++ .../src/commands/init/ios/target-picker.ts | 3 +- packages/cli-core/src/lib/spinner.ts | 65 +++++---- 11 files changed, 300 insertions(+), 34 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/progress.test.ts diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index ace883c4e..ed8748437 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -65,7 +65,12 @@ import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; import { validateAppIdPrefix } from "./ios/native-remote.ts"; import { pickAppleNativeTarget } from "./ios/target-picker.ts"; -import { compactNativeOutput, withNativeSpinner } from "./ios/presentation.ts"; +import { + compactNativeOutput, + withNativeSpinner, + withNativeProgress, + stopNativeProgress, +} from "./ios/presentation.ts"; import { prepareAppleNativeSetup, runAppleNativeDryRun, @@ -113,6 +118,10 @@ type InitOptions = { }; export async function init(options: InitOptions = {}) { + return withNativeProgress(async () => runInit(options)); +} + +async function runInit(options: InitOptions) { if (options.prebuiltAuthUI == null && options.prebuiltAuthUi != null) { options = { ...options, prebuiltAuthUI: options.prebuiltAuthUi }; } @@ -178,6 +187,7 @@ export async function init(options: InitOptions = {}) { if (!resolved) return; const { ctx, bootstrap } = resolved; + if (ctx.framework.dep !== "ios") stopNativeProgress(); if (bootstrap) { ctx.isBootstrap = true; @@ -245,6 +255,7 @@ export async function init(options: InitOptions = {}) { }; } + stopNativeProgress(); await enrichProjectContext(ctx); // Skip auth-related I/O entirely when the user opted into accountless setup — those @@ -322,6 +333,7 @@ export async function init(options: InitOptions = {}) { appIdPrefix: options.appIdPrefix, }) : undefined; + stopNativeProgress(); const authenticatedKeysHandled = appleNativeResult?.authenticatedKeysHandled ?? false; if (appleNativeResult?.nativeRemoteReady) { ctx.iosNativeRemoteReady = true; @@ -556,6 +568,7 @@ async function bootstrapAndDetect( frameworkOverride: FrameworkInfo | undefined, overrides: BootstrapOverrides, ): Promise { + stopNativeProgress(); setTelemetryStage("bootstrap"); const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides); 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 index 1e7046276..78e065892 100644 --- 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 @@ -29,6 +29,11 @@ let nativeSettingsPatchCount = 0; let iosApplicationPostCount = 0; let appleConfigPatchCount = 0; let appleConfigVersion = "v1_1234abcd"; +let responseDelayMs = 0; + +export function setApplyCLIResponseDelay(milliseconds: number): void { + responseDelayMs = milliseconds; +} let appleConnection: Record = { enabled: false, authenticatable: true, @@ -45,6 +50,7 @@ const authServer = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + if (responseDelayMs) await Bun.sleep(responseDelayMs); const url = new URL(request.url); if (request.method === "GET" && url.pathname === "/v1/platform/applications") { return Response.json([]); @@ -154,6 +160,7 @@ const authServer = Bun.serve({ authServer.unref(); export function resetApplyCLITestRemoteState(): void { + responseDelayMs = 0; nativeAPIEnabled = false; nextIOSApplication = 1; nativeSettingsPatchCount = 0; diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 0a310fcc1..16c1fdf45 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -14,6 +14,7 @@ import { withNativeSpinner as withSpinner, compactNativeOutput, printNativeLocalPreview, + stopNativeProgress, } from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { @@ -328,6 +329,7 @@ export async function applyIOSLocalSetup( ); } const platformLabel = selectedTarget.platform === "macos" ? "macOS" : "iOS"; + stopNativeProgress(); if (compactNativeOutput()) { const platforms = selectedTarget.supportedPlatforms .map((p) => (p === "ios" ? "iOS" : "macOS")) @@ -1339,6 +1341,7 @@ export async function applyIOSPlannedLocalSetup( } } + stopNativeProgress(); if (preparedSDK?.status === "ready") { log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); } @@ -1443,6 +1446,7 @@ export async function applyIOSPlannedLocalSetup( ERROR_CODE.IOS_LOCAL_APPLY_FAILED, ); } + stopNativeProgress(); if (preparedSDK?.status === "ready") { log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); } diff --git a/packages/cli-core/src/commands/init/ios/coordinator.ts b/packages/cli-core/src/commands/init/ios/coordinator.ts index ce10f097b..64cee4b36 100644 --- a/packages/cli-core/src/commands/init/ios/coordinator.ts +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -4,7 +4,11 @@ import { decodePublishableKey, fetchUserSettings } from "../../../lib/fapi.ts"; import { log } from "../../../lib/log.js"; import { interruptedExitCode } from "../../../lib/signals.ts"; import { outro } from "../../../lib/spinner.js"; -import { withNativeSpinner as withSpinner } from "./presentation.ts"; +import { + withNativeSpinner as withSpinner, + stopNativeProgress, + setNativeProgressPhase, +} from "./presentation.ts"; import { setTelemetryStage, type TelemetryStage } from "../../../lib/telemetry.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; import { @@ -100,6 +104,7 @@ export async function runAppleNativeDryRun(options: AppleNativeDryRunOptions): P }); const plan = proposal.setupPlan; const associatedDomainPlan = proposal.plannedAssociatedDomain; + stopNativeProgress(); if (options.machineOutput) { log.data( JSON.stringify( @@ -203,6 +208,7 @@ async function completeAppleNativeSetup( options: CompleteAppleNativeSetupOptions, ): Promise { if (!localSetup.requiresLinkedApp) { + setNativeProgressPhase("Applying your changes..."); setTelemetryStage("ios_local_setup"); await applyIOSPlannedLocalSetup(localSetup); return { @@ -225,6 +231,7 @@ async function completeAppleNativeSetup( } setTelemetryStage("keys"); + setNativeProgressPhase("Checking Clerk settings..."); const keys = await withSpinner("Fetching the development publishable key...", async () => resolveIOSDevelopmentPublicKey(options.applicationId!), ); @@ -324,6 +331,7 @@ async function completeAppleNativeSetup( nativeApplePlan = preparedApple; } + setNativeProgressPhase("Applying your changes..."); const commitProfile = await resolveProfile(preparation.root); if (commitProfile?.profile.appId !== options.applicationId) { throw new CliError( @@ -395,6 +403,7 @@ async function completeAppleNativeSetup( "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", "The local native Apple 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.", ); + stopNativeProgress(); log.success("Clerk Native API and application registration verified"); if (nativeApplePlan) { diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts index 96f0f8013..28ac776ba 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -17,7 +17,11 @@ import { type InstanceConfigSchema, } from "../../../lib/plapi.ts"; import { confirm } from "../../../lib/prompts.ts"; -import { withNativeSpinner as withSpinner, compactNativeOutput } from "./presentation.ts"; +import { + withNativeSpinner as withSpinner, + compactNativeOutput, + stopNativeProgress, +} from "./presentation.ts"; import type { IOSNativePlatform } from "./types.ts"; const APPLE_CONNECTION_KEY = "connection_oauth_apple"; @@ -778,6 +782,7 @@ export async function prepareIOSNativeAppleConnection( if (options.requested === false || (options.requested == null && options.agent)) { return skipped("not-requested"); } + if (options.requested == null) stopNativeProgress(); if ( options.requested == null && !(await prompts.enableNativeApple(options.bundleIdentifier.trim())) @@ -786,6 +791,7 @@ export async function prepareIOSNativeAppleConnection( } const plan = await auditIOSNativeAppleConnection(options, api); + stopNativeProgress(); if (plan.status === "blocked") { throw iosAppleError( `Native Sign in with Apple could not be enabled safely. No remote Apple connection changes were made:\n${formatBlockers(plan)}`, @@ -944,5 +950,6 @@ export async function applyIOSNativeAppleConnection( ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } + stopNativeProgress(); log.success("Native Sign in with Apple enabled in Clerk"); } diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts index ca0951e38..925893db8 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -23,7 +23,11 @@ import { type NativeSettings, } from "../../../lib/plapi.ts"; import { confirm, text } from "../../../lib/prompts.ts"; -import { withNativeSpinner as withSpinner, compactNativeOutput } from "./presentation.ts"; +import { + withNativeSpinner as withSpinner, + compactNativeOutput, + stopNativeProgress, +} from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import type { IOSNativeReadinessTarget, @@ -717,6 +721,7 @@ export async function prepareIOSNativeRemoteSetup( ), ); } + stopNativeProgress(); const appIdPrefix = await prompts.appIdPrefix(plan.bundleIdentifier!, suggestion); plan = buildIOSNativeRemotePlan({ applicationId: options.applicationId, @@ -735,6 +740,7 @@ export async function prepareIOSNativeRemoteSetup( ); } + stopNativeProgress(); if (plan.status === "satisfied") { log.info( dim( @@ -1059,9 +1065,11 @@ export async function applyIOSNativeRemoteSetup( ); } } - log.success( - `${platformName(plan.platform)} application ${plan.bundleIdentifier} registered with Clerk`, - ); + if (!compactNativeOutput()) { + log.success( + `${platformName(plan.platform)} application ${plan.bundleIdentifier} registered with Clerk`, + ); + } } if (currentPlan.nativeApi === "required") { @@ -1117,7 +1125,9 @@ export async function applyIOSNativeRemoteSetup( ); } } - log.success("Clerk Native API enabled for the development instance"); + if (!compactNativeOutput()) { + log.success("Clerk Native API enabled for the development instance"); + } } let finalPlan: IOSNativeRemotePlan; diff --git a/packages/cli-core/src/commands/init/ios/presentation.test.ts b/packages/cli-core/src/commands/init/ios/presentation.test.ts index 91ef60572..84e067a32 100644 --- a/packages/cli-core/src/commands/init/ios/presentation.test.ts +++ b/packages/cli-core/src/commands/init/ios/presentation.test.ts @@ -14,6 +14,7 @@ import { cleanupApplyCLITestState, currentNativeRemoteState, resetApplyCLITestRemoteState, + setApplyCLIResponseDelay, runCLI, } from "./apply-cli.test-helpers.ts"; @@ -94,6 +95,7 @@ test("declining the concise local preview still leaves every file unchanged", as test("human init completes local and remote setup without nested completion messages", async () => { resetApplyCLITestRemoteState(); + setApplyCLIResponseDelay(200); const root = await createUnconfiguredFixture(); const configDir = await createIsolatedCLIState(); const result = await runCLI( @@ -119,6 +121,35 @@ test("human init completes local and remote setup without nested completion mess expect(result.stderr).not.toContain("No files to scaffold"); expect(result.stderr).not.toContain("clerk env pull"); expect(result.stderr).not.toContain("Linking project"); + expect(result.stderr).toContain("Checking Clerk settings"); + expect(result.stderr).toContain("Applying your changes"); + expect(result.stderr).not.toContain("Rechecking the selected Xcode target identity"); + expect(result.stderr).not.toContain("Auditing Clerk Native Application settings"); + // Clack hides/shows the cursor when starting/ending an indicator. Within each + // uninterrupted phase, every animation frame must retain the same label. + let active: string | undefined; + let registrationApplyPhases = 0; + // oxlint-disable-next-line no-control-regex -- Check the terminal cursor's actual escape sequences. + for (const part of result.stderr.split(/(\u001b\[\?25[hl])/)) { + if (part === "\u001b[?25l") { + expect(active).toBeUndefined(); + active = ""; + } else if (part === "\u001b[?25h") { + if (active?.includes("Applying your changes")) registrationApplyPhases++; + active = undefined; + } else if (active !== undefined) { + active += part; + expect(part).not.toContain("Clerk registration changes:"); + expect(part).not.toContain("Existing web sign-in settings will be preserved"); + expect(part).not.toContain("registered with Clerk"); + expect(part).not.toContain("application registration verified"); + expect(part).not.toContain("Native Sign in with Apple enabled in Clerk"); + } + } + expect(active).toBeUndefined(); + // Registration and its API enablement/rechecks share one indicator; Apple + // connection setup shares another after the registration result is printed. + expect(registrationApplyPhases).toBe(2); expect(currentNativeRemoteState().mutations).toEqual({ nativeSettingsPatchCount: 1, iosApplicationPostCount: 1, diff --git a/packages/cli-core/src/commands/init/ios/presentation.ts b/packages/cli-core/src/commands/init/ios/presentation.ts index 01557086a..b4d3289f2 100644 --- a/packages/cli-core/src/commands/init/ios/presentation.ts +++ b/packages/cli-core/src/commands/init/ios/presentation.ts @@ -1,18 +1,58 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { isHuman } from "../../../mode.ts"; import { dim, yellow } from "../../../lib/color.ts"; import { getLogLevel, log } from "../../../lib/log.ts"; -import { withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; +import { createSpinner, withSpinner, type SpinnerControls } from "../../../lib/spinner.ts"; import type { IOSLocalSetupProposal } from "./local-plan.ts"; export function compactNativeOutput(): boolean { return isHuman() && getLogLevel() !== "debug"; } -/** Show progress for slower checks; verbose mode retains every check's history. */ +const progress = new AsyncLocalStorage<{ + message: string; + spinner?: ReturnType; +}>(); + +/** Keep one indicator across sequential checks; always clean up on command exit. */ +export async function withNativeProgress(fn: () => Promise): Promise { + if (!compactNativeOutput()) return fn(); + return progress.run({ message: "Inspecting your project..." }, async () => { + try { + return await fn(); + } catch (error) { + progress.getStore()?.spinner?.fail(error); + throw error; + } finally { + stopNativeProgress(); + } + }); +} + +/** Call before displaying a question, preview, or result. */ +export function stopNativeProgress(): void { + const state = progress.getStore(); + state?.spinner?.stop(); + if (state) state.spinner = undefined; +} + +export function setNativeProgressPhase(message: string): void { + const state = progress.getStore(); + if (!state || state.message === message) return; + stopNativeProgress(); + state.message = message; +} + +/** In compact init, individual checks share the phase's stable label and spinner. */ export async function withNativeSpinner( message: string, fn: (controls: SpinnerControls) => Promise, ): Promise { + const state = progress.getStore(); + if (state) { + state.spinner ??= createSpinner(state.message, null); + return fn({ update: () => {} }); + } return withSpinner(message, fn, compactNativeOutput() ? null : undefined); } diff --git a/packages/cli-core/src/commands/init/ios/progress.test.ts b/packages/cli-core/src/commands/init/ios/progress.test.ts new file mode 100644 index 000000000..5cb2c3fb9 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/progress.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; +import { UserAbortError } from "../../../lib/errors.ts"; +import { getLogLevel, setLogLevel } from "../../../lib/log.ts"; +import * as spinner from "../../../lib/spinner.ts"; +import { getMode, setMode } from "../../../mode.ts"; +import { + setNativeProgressPhase, + stopNativeProgress, + withNativeProgress, + withNativeSpinner, +} from "./presentation.ts"; + +const originalMode = getMode(); +const originalLevel = getLogLevel(); +let indicators: Array<{ + message: string; + update: ReturnType; + stop: ReturnType; + fail: ReturnType; +}>; +let create: ReturnType>; + +beforeEach(() => { + setMode("human"); + setLogLevel("info"); + indicators = []; + create = spyOn(spinner, "createSpinner").mockImplementation((message) => { + const indicator = { message, update: mock(), stop: mock(), fail: mock() }; + indicators.push(indicator); + return indicator; + }); +}); + +afterEach(() => { + create.mockRestore(); + setMode(originalMode); + setLogLevel(originalLevel); +}); + +test("sequential checks and nested updates keep one spinner with one phase label", async () => { + const result = await withNativeProgress(async () => { + await withNativeSpinner("Detecting framework...", async () => {}); + await withNativeSpinner("Finding targets...", async ({ update }) => { + update("Inspecting another target..."); + await withNativeSpinner("Reading build settings...", async () => {}); + }); + expect(create).toHaveBeenCalledTimes(1); + expect(indicators[0]!.stop).not.toHaveBeenCalled(); + return 42; + }); + expect(result).toBe(42); + expect(indicators[0]!.message).toBe("Inspecting your project..."); + expect(indicators[0]!.update).not.toHaveBeenCalled(); + expect(indicators[0]!.stop).toHaveBeenCalledTimes(1); +}); + +test("a prompt boundary stops progress until work resumes, and a new phase gets a new label", async () => { + await withNativeProgress(async () => { + await withNativeSpinner("Finding targets...", async () => {}); + stopNativeProgress(); + expect(indicators[0]!.stop).toHaveBeenCalledTimes(1); + await Promise.resolve(); // The user may spend any amount of time at the prompt. + expect(create).toHaveBeenCalledTimes(1); + await withNativeSpinner("Inspecting chosen target...", async () => {}); + expect(indicators[1]!.message).toBe("Inspecting your project..."); + setNativeProgressPhase("Checking Clerk settings..."); + expect(indicators[1]!.stop).toHaveBeenCalledTimes(1); + await withNativeSpinner("Fetching key...", async () => {}); + await withNativeSpinner("Checking registration...", async () => {}); + }); + expect(indicators.map(({ message }) => message)).toEqual([ + "Inspecting your project...", + "Inspecting your project...", + "Checking Clerk settings...", + ]); + expect(indicators[2]!.stop).toHaveBeenCalledTimes(1); +}); + +test.each([ + { outcome: "failure", error: new Error("Failed check") }, + { outcome: "cancellation", error: new UserAbortError() }, +])( + "$outcome cleans up and cannot leave a spinner attached to the next command", + async ({ error }) => { + await expect( + withNativeProgress(async () => { + await withNativeSpinner("Checking...", async () => { + throw error; + }); + }), + ).rejects.toBe(error); + expect(indicators[0]!.fail).toHaveBeenCalledWith(error); + expect(indicators[0]!.stop).toHaveBeenCalled(); + await withNativeProgress(async () => { + await withNativeSpinner("Checking again...", async () => {}); + }); + expect(create).toHaveBeenCalledTimes(2); + expect(indicators[1]!.fail).not.toHaveBeenCalled(); + expect(indicators[1]!.stop).toHaveBeenCalledTimes(1); + }, +); + +test.each(["verbose", "agent"] as const)( + "%s mode retains individual check handling", + async (mode) => { + if (mode === "agent") setMode("agent"); + else setLogLevel("debug"); + const individual = spyOn(spinner, "withSpinner").mockImplementation(async (_message, fn) => + fn({ update: () => {} }), + ); + try { + await withNativeProgress(async () => { + await withNativeSpinner("Detecting framework...", async () => {}); + await withNativeSpinner("Finding targets...", async () => {}); + }); + expect(create).not.toHaveBeenCalled(); + expect(individual.mock.calls.map(([message]) => message)).toEqual([ + "Detecting framework...", + "Finding targets...", + ]); + } finally { + individual.mockRestore(); + } + }, +); diff --git a/packages/cli-core/src/commands/init/ios/target-picker.ts b/packages/cli-core/src/commands/init/ios/target-picker.ts index 6245192de..6f6ead92e 100644 --- a/packages/cli-core/src/commands/init/ios/target-picker.ts +++ b/packages/cli-core/src/commands/init/ios/target-picker.ts @@ -1,5 +1,5 @@ import { select } from "../../../lib/listage.ts"; -import { withNativeSpinner as withSpinner } from "./presentation.ts"; +import { withNativeSpinner as withSpinner, stopNativeProgress } from "./presentation.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; /** Choose a target using the same selector and discovery rules as explicit --target. */ @@ -55,5 +55,6 @@ export async function pickAppleNativeTarget(options: { if (choices.every((choice) => choice.disabled)) return undefined; // Planning inspects again after the prompt; this inventory never authorizes edits. + stopNativeProgress(); return select({ message: "Which application target would you like to set up?", choices }); } diff --git a/packages/cli-core/src/lib/spinner.ts b/packages/cli-core/src/lib/spinner.ts index be929d9be..335c98eb4 100644 --- a/packages/cli-core/src/lib/spinner.ts +++ b/packages/cli-core/src/lib/spinner.ts @@ -129,13 +129,9 @@ export async function withGutter( } } -export async function withSpinner( - message: string, - fn: (controls: SpinnerControls) => Promise, - doneMessage?: string | null, -): Promise { - if (!isHuman()) return fn({ update: () => {} }); - +/** A spinner whose owner explicitly ends it at a prompt, result, or phase boundary. */ +export function createSpinner(message: string, doneMessage?: string | null) { + let finished = false; const transient = doneMessage === null; let s: ReturnType | undefined; let currentMessage = message; @@ -146,28 +142,51 @@ export async function withSpinner( }); s.start(currentMessage); }; - // Fast checks should never touch the terminal. Delay only the indicator, - // not the work or the next prompt; verbose progress still starts immediately. - const timer = transient ? setTimeout(start, TRANSIENT_SPINNER_DELAY_MS) : undefined; - if (!transient) start(); + // Delay the indicator, not the work. Fast phases never touch the terminal. + const timer = isHuman() && transient ? setTimeout(start, TRANSIENT_SPINNER_DELAY_MS) : undefined; + if (isHuman() && !transient) start(); + const finish = () => { + if (finished) return false; + finished = true; + clearTimeout(timer); + return true; + }; + return { + update(nextMessage: string) { + if (finished) return; + currentMessage = nextMessage; + s?.message(nextMessage); + }, + stop() { + if (!finish()) return; + if (transient) s?.clear(); + else s?.stop(doneMessage ?? message.replace(/\.{3}$/, "")); + }, + fail(error: unknown) { + if (!finish()) return; + if (isCancelled(error)) s?.stop(message.replace(/\.{3}$/, "")); + else s?.error("Failed"); + }, + }; +} + +export async function withSpinner( + message: string, + fn: (controls: SpinnerControls) => Promise, + doneMessage?: string | null, +): Promise { + if (!isHuman()) return fn({ update: () => {} }); + + const s = createSpinner(message, doneMessage); try { - const result = await fn({ - update: (nextMessage) => { - currentMessage = nextMessage; - s?.message(nextMessage); - }, - }); - if (transient) s?.clear(); - else s?.stop(doneMessage ?? message.replace(/\.{3}$/, "")); + const result = await fn({ update: s.update }); + s.stop(); return result; } catch (error) { // An interrupt aborts whatever the spinner was waiting on, so the rejection // arrives here first. Rendering "Failed" for a cancel the user asked for is // wrong, and it prints before the SIGINT handler finishes exiting. - if (isCancelled(error)) s?.stop(message.replace(/\.{3}$/, "")); - else s?.error("Failed"); + s.fail(error); throw error; - } finally { - clearTimeout(timer); } } From 1f5c37b0ed4df7dd699fca8c0dd742a939ed3067 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:58:12 -0400 Subject: [PATCH 12/16] fix(init): tailor sign-in guidance to the existing app --- .../src/commands/init/frameworks/ios.test.ts | 32 ++++++++++++------- .../src/commands/init/frameworks/ios.ts | 12 ++++--- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index c772c0d1a..1d12c483a 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, test, expect, spyOn } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { cp, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ios } from "./ios.ts"; @@ -67,10 +67,8 @@ test("writes no files and prints the quickstart steps", async () => { plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), ).toBe(true); expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); - 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).toContain("Add a sign-in screen using ClerkKitUI’s `AuthView`."); + expect(plan.postInstructions.some((i) => i.includes("--prebuilt-auth-ui"))).toBe(false); 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); @@ -203,14 +201,27 @@ test("preserves a custom LocalSecrets loader without interpreting its value", as expect(plan.postInstructions.some((i) => i.includes(".env"))).toBe(false); }); -test("includes SwiftUI environment injection for the default prebuilt path", async () => { +test("offers the prebuilt command only for an eligible starter screen", async () => { const root = await makeIOSFixture(false); + await cp(join(import.meta.dir, "../../../../../../test/e2e/fixtures/ios"), root, { + recursive: true, + }); 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, + expect(plan.postInstructions).toContain( + "To add Clerk’s sign-in screen, run `clerk init --prebuilt-auth-ui`.", ); + const contentPath = join(root, "MyApp/ContentView.swift"); + const existingScreen = (await Bun.file(contentPath).text()).replace("Hello, world!", "My notes"); + await Bun.write(contentPath, existingScreen); + const established = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + expect(established.postInstructions).toContain( + "Add a sign-in screen using ClerkKitUI’s `AuthView`.", + ); + expect(established.postInstructions.join("\n")).not.toContain("--prebuilt-auth-ui"); + expect(established.postInstructions.join("\n")).toContain("docs/ios/getting-started/quickstart"); + expect(await Bun.file(contentPath).text()).toBe(existingScreen); }); test("keeps existing custom-flow installation and environment guidance core-only", async () => { @@ -262,9 +273,8 @@ test("does not derive setup state from a LocalSecrets value", async () => { expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(false); expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(true); expect(plan.postInstructions.some((i) => i.includes("Configure Clerk"))).toBe(false); - expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( - false, - ); + expect(plan.postInstructions.join("\n")).not.toContain("Add a sign-in screen"); + expect(plan.postInstructions.join("\n")).not.toContain("--prebuilt-auth-ui"); expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false); expect( diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 7ac24276c..eb83992de 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -130,11 +130,13 @@ export const ios: FrameworkScaffold = { : []; 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", + proposal.prebuiltAuthPlanForSetup?.status === "ready" + ? "To add Clerk’s sign-in screen, run `clerk init --prebuilt-auth-ui`." + : productDecision === "core-only" + ? "Add a sign-in screen using a custom ClerkKit flow." + : productDecision === "unknown" + ? "Choose ClerkKitUI’s `AuthView` or a custom ClerkKit flow for your sign-in screen." + : "Add a sign-in screen using ClerkKitUI’s `AuthView`.", ] : []; const nativeAppleInstructions = ctx.iosNativeAppleReady From 99d3820adbd77a82fb5c5a84035d9e413f606f69 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 12:58:13 -0400 Subject: [PATCH 13/16] docs(changeset): include clearer native setup guidance --- .changeset/ios-established-app-operation-gates.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/ios-established-app-operation-gates.md b/.changeset/ios-established-app-operation-gates.md index 0058b4f6a..a58cd510b 100644 --- a/.changeset/ios-established-app-operation-gates.md +++ b/.changeset/ios-established-app-operation-gates.md @@ -2,4 +2,4 @@ "clerk": patch --- -Improve native Apple setup with clearer previews and progress, safe independent operations in established apps, capability edits isolated from unrelated Associated Domains issues, and Doctor registration checks when Swift source discovery is incomplete. +Improve native Apple setup with clearer setup messages, safe independent operations in established apps, capability edits isolated from unrelated Associated Domains issues, and Doctor registration checks when Swift source discovery is incomplete. From 879a4c98d3cf88e829348c70be7bd7998db5c575 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 14:38:36 -0400 Subject: [PATCH 14/16] test(init): match concise iOS setup summary --- test/e2e/native-init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/native-init.test.ts b/test/e2e/native-init.test.ts index fccaf2a67..e0bdf96b6 100644 --- a/test/e2e/native-init.test.ts +++ b/test/e2e/native-init.test.ts @@ -19,7 +19,7 @@ const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); const PLATFORMS = [ { fixture: "ios", - detectedName: "iOS (Swift)", + detectedName: "Found iOS app: MyApp", instructions: [ "ClerkKit and ClerkKitUI linked to MyApp", "Clerk configured in MyApp/MyAppApp.swift", From 2c8e9682b3cee45a104166c1a56615e23607cb0a Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 19:12:32 -0400 Subject: [PATCH 15/16] test(native): decouple remote progress assertions from local timing --- docs/native-established-apps.md | 6 ++++++ .../src/commands/init/ios/presentation.test.ts | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md index 1e416519b..8afef3d7e 100644 --- a/docs/native-established-apps.md +++ b/docs/native-established-apps.md @@ -33,6 +33,12 @@ still block edits. Any permitted edit preserves unrelated capability values. | Configure associated domains | Proven domain/key inputs and the capability planner's ownership checks | An unproven custom startup call does not supply a domain; preserve domain values and report manual follow-up | | Diagnose key matching, AuthView, or Apple authentication | The relevant runtime, source, entitlement, and linked-app evidence | Doctor's registration-only fallback does not run these checks or imply they passed | +Registration uses the effective `CFBundleIdentifier`: generated plist settings or +an explicit XML Info.plist with supported build-setting expansion. The identity +must agree across configurations, platforms, and compiler/packaging contexts. +Missing, unreadable, preprocessed, or otherwise unresolved plist inputs block +registration; selecting `--app` does not bypass those checks. + `init` still rejects incomplete source discovery before edits: its SDK choice and combined local plan depend on that evidence. Doctor is read-only and can retain a source-discovery failure while reporting an independently supported registration diff --git a/packages/cli-core/src/commands/init/ios/presentation.test.ts b/packages/cli-core/src/commands/init/ios/presentation.test.ts index 84e067a32..3ad19ce0b 100644 --- a/packages/cli-core/src/commands/init/ios/presentation.test.ts +++ b/packages/cli-core/src/commands/init/ios/presentation.test.ts @@ -129,13 +129,18 @@ test("human init completes local and remote setup without nested completion mess // uninterrupted phase, every animation frame must retain the same label. let active: string | undefined; let registrationApplyPhases = 0; + let localApplyPhases = 0; + let localResultsPrinted = false; // oxlint-disable-next-line no-control-regex -- Check the terminal cursor's actual escape sequences. for (const part of result.stderr.split(/(\u001b\[\?25[hl])/)) { if (part === "\u001b[?25l") { expect(active).toBeUndefined(); active = ""; } else if (part === "\u001b[?25h") { - if (active?.includes("Applying your changes")) registrationApplyPhases++; + if (active?.includes("Applying your changes")) { + if (localResultsPrinted) registrationApplyPhases++; + else localApplyPhases++; + } active = undefined; } else if (active !== undefined) { active += part; @@ -144,11 +149,17 @@ test("human init completes local and remote setup without nested completion mess expect(part).not.toContain("registered with Clerk"); expect(part).not.toContain("application registration verified"); expect(part).not.toContain("Native Sign in with Apple enabled in Clerk"); + } else if (part.includes("Sign in with Apple entitlement added to the selected target")) { + localResultsPrinted = true; } } expect(active).toBeUndefined(); // Registration and its API enablement/rechecks share one indicator; Apple // connection setup shares another after the registration result is printed. + expect(localResultsPrinted).toBe(true); + // A fast local transaction finishes before the spinner's display delay. A + // slower machine may show it once before printing the local results. + expect(localApplyPhases).toBeLessThanOrEqual(1); expect(registrationApplyPhases).toBe(2); expect(currentNativeRemoteState().mutations).toEqual({ nativeSettingsPatchCount: 1, From 5dd8c8ca9d918910449e65496864c51c6576f9a4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 24 Sep 2026 20:04:06 -0400 Subject: [PATCH 16/16] docs(native): clarify entitlement packaging checks --- docs/native-established-apps.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/native-established-apps.md b/docs/native-established-apps.md index 8afef3d7e..2eab430f5 100644 --- a/docs/native-established-apps.md +++ b/docs/native-established-apps.md @@ -18,7 +18,9 @@ runtime wiring is unresolved, it does not activate automatic AuthView setup or provider-capability changes. SDK linkage and native registration may still proceed. Explicit `--prebuilt-auth-ui` requests retain the runtime checks. -Entitlement edits require safe file selection and proven ownership. An unresolved +Entitlement edits require safe file selection and proven ownership. File paths +and each capability’s evaluated settings must agree with Xcode’s packaging +settings as well as its compiler settings. An unresolved or malformed Associated Domains value blocks editing that capability, but does not by itself block a Sign in with Apple or macOS networking edit in the same valid XML dictionary. Shared files, unresolved paths, and invalid plist structure