diff --git a/.changeset/macos-native-setup.md b/.changeset/macos-native-setup.md new file mode 100644 index 000000000..7894dfff0 --- /dev/null +++ b/.changeset/macos-native-setup.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native macOS project support to `clerk init` and `clerk doctor`. diff --git a/packages/cli-core/src/cli-program.test.ts b/packages/cli-core/src/cli-program.test.ts index 8654d18ee..2301420fb 100644 --- a/packages/cli-core/src/cli-program.test.ts +++ b/packages/cli-core/src/cli-program.test.ts @@ -23,6 +23,20 @@ test("does not register the removed clerk skill command", () => { expect(skill).toBeUndefined(); }); +test("doctor help describes static iOS and macOS target audits", () => { + const program = createProgram(); + const doctor = program.commands.find((command) => command.name() === "doctor")!; + const help = doctor.helpInformation(); + + expect(help).toContain("Select an iOS or macOS application target"); + expect(help).toContain("clerk doctor --target MyApp"); + expect(help).toContain("Audit a specific iOS or macOS application"); + expect(help).not.toContain("--build"); + expect(help).not.toContain("--resolve-packages"); + expect(help).not.toContain("--simulator"); + expect(help).not.toContain("--device"); +}); + test("registers users create and list as subcommands", () => { const program = createProgram(); const users = program.commands.find((command) => command.name() === "users")!; diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index dee82eb56..5036e196e 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -17,13 +17,13 @@ clerk doctor --target MyApp ## Options -| Flag | Description | -| ------------- | ----------------------------------------------------- | -| `--verbose` | Show detailed diagnostic info for each check | -| `--json` | Output results as machine-readable JSON | -| `--spotlight` | Only show warnings and failures (hide passing checks) | -| `--fix` | Offer to auto-fix issues with known remedies | -| `--target` | Select an iOS application target by name or object ID | +| Flag | Description | +| ------------- | -------------------------------------------------------------- | +| `--verbose` | Show detailed diagnostic info for each check | +| `--json` | Output results as machine-readable JSON | +| `--spotlight` | Only show warnings and failures (hide passing checks) | +| `--fix` | Offer to auto-fix issues with known remedies | +| `--target` | Select an iOS or macOS application target by name or object ID | ## Checks @@ -34,29 +34,33 @@ clerk doctor --target MyApp | Project linkage | Project | Current directory is linked to a Clerk app | | Linked application | Project | Linked application ID is accessible via the API | | Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | Non-iOS projects have Clerk keys in `.env.local` or `.env` | +| Environment variables | Environment | Projects without a supported iOS or macOS app have Clerk keys in `.env.local` or `.env` | | CLI configuration | Configuration | CLI config file exists and parses | | Shell completion | Configuration | Shell autocompletion is installed for the detected shell | | MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | -### iOS projects +### iOS and macOS projects -When the current directory contains an Xcode project or `--target` is provided, -doctor replaces the web `.env` check with the same semantic Xcode, Swift, and -entitlements inspection used by `clerk init`. It reports separate results for: +When the current directory contains a supported iOS or macOS application target, +or `--target` is provided, Doctor replaces the web `.env` check with the same +semantic Xcode, Swift, and entitlements inspection used by `clerk init`. It +reports separate results for: - application-target selection; - ClerkKit and ClerkKitUI product linkage; - `Clerk.configure` and, for direct literal configuration, the selected target's effective development key; - SwiftUI environment injection and authentication-flow evidence; - AuthView's enabled methods and required local Apple capability; -- Associated Domains and the optional Sign in with Apple entitlement; +- iOS Associated Domains or the macOS outgoing-network capability; +- the optional Sign in with Apple entitlement; - Native API state and the exact Bundle ID registration on the linked development instance; and - the Clerk Apple connection when the selected target already declares the native Apple entitlement. -iOS diagnostics never require a secret key in the Xcode project or an env +Doctor can still inspect and diagnose an iOS or macOS target that also ships visionOS or explicitly enables Mac Catalyst. It reports that platform boundary as a failure instead of treating the integration as ready for automatic setup; `clerk init` then applies no new Clerk setup changes and performs no remote writes. Doctor itself remains read-only. + +Native Apple diagnostics never require a secret key in the Xcode project or an env file. A direct literal publishable key is compared with the linked development application using only redacted Frontend API host metadata. For a single startup `Clerk.configure` call that uses a custom publishable-key source, @@ -157,8 +161,8 @@ Exit code 1 signals one or more checks failed. | ------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | `GET` | `/oauth/userinfo` | Validates the stored auth token | | `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist | -| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_settings` | Verifies Native API state for iOS projects | -| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_applications/ios` | Verifies the exact iOS Bundle ID registration | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_settings` | Verifies Native API state for iOS and macOS projects | +| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_applications/ios` | Verifies the exact native Apple Bundle ID registration | | `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Audits the Apple connection when native Apple is relevant | | `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config/schema` | Determines whether an unhealthy Apple connection can be safely reconciled by init | | `GET` | `https://{fapiHost}/v1/environment` | Verifies whether AuthView currently offers native Apple sign-in | diff --git a/packages/cli-core/src/commands/doctor/index.test.ts b/packages/cli-core/src/commands/doctor/index.test.ts index 8b7836122..553154c84 100644 --- a/packages/cli-core/src/commands/doctor/index.test.ts +++ b/packages/cli-core/src/commands/doctor/index.test.ts @@ -15,7 +15,44 @@ const IOS_FRAMEWORK: FrameworkInfo = { ecosystem: "swift", }; -const IOS_INSPECTION = {} as IOSProjectInspectionResult; +const IOS_INSPECTION = { + platform: "ios", + appTargets: [{ platform: "ios" }], + selection: { state: "selected", platform: "ios" }, +} as IOSProjectInspectionResult; +const MACOS_INSPECTION = { + platform: "macos", + appTargets: [{ platform: "macos" }], + selection: { state: "selected", platform: "macos" }, +} as IOSProjectInspectionResult; +const UNSUPPORTED_XCODE_INSPECTION = { + schemaVersion: 1, + platform: "apple-native", + root: "/fixture", + workspaces: [], + projects: [], + appTargets: [], + selection: { state: "none" }, + localPublishableKey: { state: "missing" }, + generatedProject: null, + diagnostics: [ + { + code: "xcode.no-ios-app-target", + severity: "error", + message: "No supported iOS or macOS application target was found.", + evidence: [], + }, + ], +} as IOSProjectInspectionResult; +const MISSING_TARGET_INSPECTION = { + ...UNSUPPORTED_XCODE_INSPECTION, + platform: "ios", + selection: { + state: "not-found", + requested: "MissingApp", + candidates: ["MyApp (APP_TARGET)"], + }, +} as IOSProjectInspectionResult; const DOCTOR_CONTEXT = {} as DoctorContext; function passingResult(name: string): CheckResult { @@ -25,6 +62,7 @@ function passingResult(name: string): CheckResult { function runDependencies(overrides: Partial = {}): DoctorRunDependencies { return { detectFramework: async () => IOS_FRAMEWORK, + inspectIOSProject: async () => IOS_INSPECTION, getDoctorChecks: () => [async () => passingResult("Common")], runIOSDoctorChecks: async () => ({ inspection: IOS_INSPECTION, @@ -41,6 +79,144 @@ describe("getDoctorChecks", () => { }); }); +describe("Apple-native framework routing", () => { + test("keeps a pure macOS application on native Clerk checks", async () => { + let nativeChecks = false; + const results = await runChecks( + DOCTOR_CONTEXT, + {}, + { + dependencies: runDependencies({ + inspectIOSProject: async () => MACOS_INSPECTION, + getDoctorChecks: (native) => { + nativeChecks = native; + return [async () => passingResult("Common")]; + }, + runIOSDoctorChecks: async (_ctx, options) => ({ + inspection: options.preparedInspection ?? MACOS_INSPECTION, + results: [passingResult("macOS")], + }), + }), + }, + ); + + expect(nativeChecks).toBeTrue(); + expect(results.map((result) => result.name)).toEqual(["Common", "macOS"]); + }); + + test("uses ordinary checks for an unsupported Xcode-only project", async () => { + let nativeChecks = true; + let nativeAuditCalls = 0; + const results = await runChecks( + DOCTOR_CONTEXT, + {}, + { + dependencies: runDependencies({ + inspectIOSProject: async () => UNSUPPORTED_XCODE_INSPECTION, + getDoctorChecks: (native) => { + nativeChecks = native; + return [async () => passingResult(native ? "Native" : "Environment variables")]; + }, + runIOSDoctorChecks: async () => { + nativeAuditCalls++; + return { inspection: UNSUPPORTED_XCODE_INSPECTION, results: [] }; + }, + }), + }, + ); + + expect(nativeChecks).toBeFalse(); + expect(nativeAuditCalls).toBe(0); + expect(results.map((result) => result.name)).toEqual(["Environment variables"]); + }); + + test.each([ + ["xcode.malformed-project", "Could not parse App.xcodeproj/project.pbxproj."], + ["xcode.missing-project-file", "App.xcodeproj does not contain project.pbxproj."], + ] as const)("fails native inspection for %s", async (code, message) => { + const failedInspection = { + ...UNSUPPORTED_XCODE_INSPECTION, + diagnostics: [ + { + code, + severity: "error" as const, + message, + remedy: "Repair the Xcode project file.", + evidence: [], + }, + ], + } as IOSProjectInspectionResult; + let nativeChecks = false; + let nativeAuditCalls = 0; + + const results = await runChecks( + DOCTOR_CONTEXT, + {}, + { + dependencies: runDependencies({ + inspectIOSProject: async () => failedInspection, + getDoctorChecks: (native) => { + nativeChecks = native; + return [async () => passingResult(native ? "Native" : "Environment variables")]; + }, + runIOSDoctorChecks: async () => { + nativeAuditCalls++; + return { inspection: failedInspection, results: [] }; + }, + }), + }, + ); + + expect(nativeChecks).toBeTrue(); + expect(nativeAuditCalls).toBe(0); + expect(results).toEqual([ + passingResult("Native"), + { + name: "Apple-native inspection", + status: "fail", + message: "Apple-native project inspection failed", + detail: message, + remedy: "Repair the Xcode project file.", + }, + ]); + }); + + test("routes a missing explicit target through the native audit", async () => { + let nativeChecks = false; + let nativeAuditCalls = 0; + const results = await runChecks( + DOCTOR_CONTEXT, + { target: "MissingApp" }, + { + dependencies: runDependencies({ + inspectIOSProject: async () => MISSING_TARGET_INSPECTION, + getDoctorChecks: (native) => { + nativeChecks = native; + return [async () => passingResult("Common")]; + }, + runIOSDoctorChecks: async (_ctx, options) => { + nativeAuditCalls++; + expect(options.preparedInspection).toBe(MISSING_TARGET_INSPECTION); + return { + inspection: MISSING_TARGET_INSPECTION, + results: [ + { + name: "iOS: Select the iOS application target", + status: "fail", + message: 'The requested target "MissingApp" was not found.', + }, + ], + }; + }, + }), + }, + ); + + expect(nativeChecks).toBeTrue(); + expect(nativeAuditCalls).toBe(1); + expect(results.some((result) => result.status === "fail")).toBeTrue(); + }); +}); describe("doctor telemetry stages", () => { test("reports the ordered native diagnostic boundaries", async () => { const stage = spyOn(telemetryMod, "setTelemetryStage"); diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 030d0a0e1..57c08ff3c 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -6,6 +6,7 @@ import { log } from "../../lib/log.ts"; import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { setTelemetryStage } from "../../lib/telemetry.ts"; +import { inspectIOSProject } from "../init/ios/inspect.ts"; import { createDoctorContext } from "./context.ts"; import { checkLoggedIn, @@ -35,19 +36,25 @@ const ACCOUNT_CHECKS: CheckFn[] = [ const CONFIGURATION_CHECKS: CheckFn[] = [checkConfigFile, checkShellCompletion, checkMcp]; -export function getDoctorChecks(ios: boolean): CheckFn[] { - const checks = [...ACCOUNT_CHECKS, ...(ios ? [] : [checkEnvVars]), ...CONFIGURATION_CHECKS]; +export function getDoctorChecks(appleNative: boolean): CheckFn[] { + const checks = [ + ...ACCOUNT_CHECKS, + ...(appleNative ? [] : [checkEnvVars]), + ...CONFIGURATION_CHECKS, + ]; return isAgent() ? [checkHostExecution, ...checks] : checks; } export interface DoctorRunDependencies { detectFramework: typeof detectFramework; + inspectIOSProject: typeof inspectIOSProject; getDoctorChecks: typeof getDoctorChecks; runIOSDoctorChecks: typeof runIOSDoctorChecks; } const defaultDoctorRunDependencies: DoctorRunDependencies = { detectFramework, + inspectIOSProject, getDoctorChecks, runIOSDoctorChecks, }; @@ -64,13 +71,38 @@ export async function runChecks( ): Promise { const dependencies = runOptions.dependencies ?? defaultDoctorRunDependencies; setTelemetryStage(runOptions.initialStage ?? "doctor_checks"); - const explicitlyRequestsIOS = options.target != null; - const framework = explicitlyRequestsIOS + const explicitlyRequestsAppleNative = options.target != null; + const framework = explicitlyRequestsAppleNative ? { dep: "ios" } : await dependencies.detectFramework(process.cwd()); - const ios = framework?.dep === "ios"; + const appleNativeCandidate = framework?.dep === "ios"; + let appleNativeInspection: Awaited> | undefined; + let appleNativeInspectionFailed = false; + if (appleNativeCandidate) { + try { + appleNativeInspection = await dependencies.inspectIOSProject(process.cwd(), { + ...(options.target ? { target: options.target } : {}), + exhaustiveContainerDiscovery: true, + }); + } catch { + appleNativeInspectionFailed = true; + } + } + const fatalAppleNativeInspectionDiagnostic = + appleNativeInspection?.appTargets.length === 0 && + appleNativeInspection.selection.state === "none" + ? appleNativeInspection.diagnostics.find( + (diagnostic) => + diagnostic.severity === "error" && diagnostic.code !== "xcode.no-ios-app-target", + ) + : undefined; + const appleNative = + appleNativeInspectionFailed || + fatalAppleNativeInspectionDiagnostic != null || + options.target != null || + (appleNativeInspection?.appTargets.length ?? 0) > 0; const common = await Promise.all( - dependencies.getDoctorChecks(ios).map(async (check) => { + dependencies.getDoctorChecks(appleNative).map(async (check) => { try { return await check(ctx); } catch (error) { @@ -83,26 +115,56 @@ export async function runChecks( }), ); - if (!ios) return common; + if (!appleNativeCandidate) return common; + if (appleNativeInspectionFailed || !appleNativeInspection) { + return [ + ...common, + { + name: "Apple-native inspection", + status: "fail", + message: "Apple-native project inspection failed", + detail: "The semantic Xcode inspection did not complete safely.", + remedy: "Run from the Xcode project root and pass `--target ` if needed.", + }, + ]; + } + if (fatalAppleNativeInspectionDiagnostic) { + return [ + ...common, + { + name: "Apple-native inspection", + status: "fail", + message: "Apple-native project inspection failed", + detail: fatalAppleNativeInspectionDiagnostic.message, + remedy: + fatalAppleNativeInspectionDiagnostic.remedy ?? + "Run from the Xcode project root and verify that its project files are readable.", + }, + ]; + } + if (!appleNative) return common; + + let appleNativeChecks: Awaited>; try { setTelemetryStage("doctor_ios_audit"); - const iosChecks = await dependencies.runIOSDoctorChecks(ctx, { + appleNativeChecks = await dependencies.runIOSDoctorChecks(ctx, { root: process.cwd(), ...(options.target ? { target: options.target } : {}), + preparedInspection: appleNativeInspection, }); - return [...common, ...iosChecks.results]; } catch { return [ ...common, { - name: "iOS inspection", + name: "Apple-native inspection", status: "fail", - message: "iOS project inspection failed", + message: "Apple-native project inspection failed", detail: "The semantic Xcode inspection did not complete safely.", remedy: "Run from the Xcode project root and pass `--target ` if needed.", }, ]; } + return [...common, ...appleNativeChecks.results]; } function printResults(results: CheckResult[], options: DoctorOptions): void { @@ -208,7 +270,7 @@ export function registerDoctor(program: Program): void { .option("--json", "Output results as JSON") .option("--spotlight", "Only show warnings and failures") .option("--fix", "Attempt to auto-fix issues") - .option("--target ", "Select an iOS application target") + .option("--target ", "Select an iOS or macOS application target") .setExamples([ { command: "clerk doctor", description: "Run all health checks" }, { command: "clerk doctor --verbose", description: "Show detailed output for each check" }, @@ -217,7 +279,7 @@ export function registerDoctor(program: Program): void { { command: "clerk doctor --spotlight", description: "Only show warnings and failures" }, { command: "clerk doctor --target MyApp", - description: "Audit a specific iOS application target", + description: "Audit a specific iOS or macOS application target", }, ]) .action(doctor); diff --git a/packages/cli-core/src/commands/doctor/ios.test.ts b/packages/cli-core/src/commands/doctor/ios.test.ts index 2c23a9412..ab1593809 100644 --- a/packages/cli-core/src/commands/doctor/ios.test.ts +++ b/packages/cli-core/src/commands/doctor/ios.test.ts @@ -2,10 +2,16 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createIOSFixture, IOS_FIXTURE_IDS } from "../init/ios/test-helpers.ts"; +import { + convertIOSFixtureToMultiplatform, + convertIOSFixtureToPlatformFilteredAppRoots, + createIOSFixture, + IOS_FIXTURE_IDS, +} from "../init/ios/test-helpers.ts"; import { planIOSSDKInstall } from "../init/ios/install-sdk.ts"; +import { planMacOSNetworkCapability } from "../init/ios/macos-network.ts"; import type { IOSNativeAppleBlockerCode } from "../init/ios/native-apple.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import type { UserSettingsJSON } from "../../lib/fapi.ts"; import type { Application } from "../../lib/plapi.ts"; import { auditIOSPrebuiltAuthEnvironment } from "../init/ios/prebuilt-auth-environment.ts"; @@ -74,6 +80,24 @@ async function fixture(options: Parameters[1] = {}): Pr return root; } +async function makeMultiplatform(root: string, macOSDeploymentTarget = "14.0"): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await readFile(projectPath, "utf8"); + await writeFile( + projectPath, + project + .replaceAll("SDKROOT = iphoneos;", "SDKROOT = auto;") + .replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";', + ) + .replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0;", + `IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = ${macOSDeploymentTarget}; ENABLE_APP_SANDBOX = NO;`, + ), + ); +} + async function addAppleEntitlement( root: string, value = "Default", @@ -86,6 +110,19 @@ async function addAppleEntitlement( ); } +async function setMacOSNetworkEntitlement(root: string, state: "absent" | "false"): Promise { + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const replacement = + state === "false" ? "\ncom.apple.security.network.client" : ""; + await writeFile( + entitlementsPath, + (await readFile(entitlementsPath, "utf8")).replace( + /\s*com\.apple\.security\.network\.client<\/key>\s*/, + replacement, + ), + ); +} + async function writeSelectedTargetRunSchemeKey(root: string, key: string): Promise { const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); await mkdir(schemeDirectory, { recursive: true }); @@ -146,6 +183,7 @@ function dependencies(overrides: Partial = {}): IOSDoctor ); }), planIOSSDKInstall: overrides.planIOSSDKInstall ?? planIOSSDKInstall, + planMacOSNetworkCapability: overrides.planMacOSNetworkCapability ?? planMacOSNetworkCapability, }; } @@ -154,6 +192,259 @@ afterEach(async () => { }); describe("runIOSDoctorChecks", () => { + test("fails without remote reads when supported platforms use different Swift app roots", async () => { + const root = await fixture({ complete: true }); + await convertIOSFixtureToPlatformFilteredAppRoots(root); + let remoteReads = 0; + const unexpectedRemoteRead = async (): Promise => { + remoteReads += 1; + throw new Error("remote state must not be read for a divergent platform target"); + }; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchApplication: unexpectedRemoteRead, + getNativeSettings: unexpectedRemoteRead, + listIOSApplications: unexpectedRemoteRead, + fetchUserSettings: unexpectedRemoteRead, + }), + ); + + expect(remoteReads).toBe(0); + expect( + audit.results.find((result) => result.name === "iOS: Validate the multiplatform target"), + ).toMatchObject({ + status: "fail", + detail: expect.stringContaining("different Swift application roots"), + }); + }); + + test("runs and labels the macOS network check for a primary-iOS multiplatform target", async () => { + const root = await fixture({ complete: true }); + await makeMultiplatform(root); + const { inspectIOSProject } = await import("../init/ios/inspect.ts"); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const target = inspection.appTargets[0]; + if (!target) throw new Error("Expected an application target"); + let networkPlanCalls = 0; + const sdkPlannerOptions: Parameters[0][] = []; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp", preparedInspection: inspection }, + dependencies({ + planIOSSDKInstall: async (options) => { + sdkPlannerOptions.push(options); + return planIOSSDKInstall(options); + }, + planMacOSNetworkCapability: async (options) => { + networkPlanCalls += 1; + return { + schemaVersion: 1, + kind: "clerk-macos-network-capability", + status: "satisfied", + root: options.root, + projectPath: options.projectPath, + targetId: options.targetId, + targetName: "MyApp", + files: [], + actions: [], + blockers: [], + }; + }, + }), + ); + + expect(networkPlanCalls).toBe(1); + expect(sdkPlannerOptions).toEqual([ + { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", + supportedPlatforms: ["ios", "macos"], + includeClerkKitUI: true, + requirePrebuiltAuthCompatibility: true, + }, + ]); + expect(audit.inspection.selection).toMatchObject({ state: "selected", platform: "ios" }); + expect( + audit.results.find((result) => result.name === "macOS: Allow outgoing network access") + ?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "iOS: Add Clerk's associated domain"), + ).toBeDefined(); + }); + + test("fails when a multiplatform target is missing the macOS Apple entitlement", async () => { + const root = await fixture({ complete: true }); + await addAppleEntitlement(root); + await writeFile( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.client`, + ); + await convertIOSFixtureToMultiplatform(root); + let appleHealthCalls = 0; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => + ({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + }) as UserSettingsJSON, + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async () => { + appleHealthCalls++; + return { + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", blockers: [] }, + } as never; + }, + }), + ); + + const entitlement = audit.results.find( + (result) => result.name === "iOS: Sign in with Apple entitlement", + ); + expect(entitlement).toMatchObject({ + status: "fail", + message: "Sign in with Apple entitlement: incomplete", + }); + expect(entitlement?.detail).toContain("macOS"); + expect( + audit.results.find((result) => result.name === "iOS: AuthView authentication methods"), + ).toMatchObject({ + status: "fail", + message: "AuthView offers Apple sign-in but the selected target lacks its entitlement", + }); + expect(appleHealthCalls).toBe(1); + }); + + test("detects Apple entitlement intent present only in the secondary macOS view", async () => { + const root = await fixture({ complete: true }); + await writeFile( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.clientcom.apple.developer.applesigninDefault`, + ); + await convertIOSFixtureToMultiplatform(root); + const plannerOptions: Array[0]> = + []; + let appleHealthCalls = 0; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + plannerOptions.push(options); + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async () => { + appleHealthCalls += 1; + return { + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", blockers: [] }, + } as never; + }, + }), + ); + + expect(plannerOptions).toEqual([ + { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", + supportedPlatforms: ["ios", "macos"], + }, + ]); + expect( + audit.results.find((result) => result.name === "iOS: Sign in with Apple entitlement"), + ).toMatchObject({ + status: "fail", + message: "Sign in with Apple entitlement: incomplete", + }); + expect( + audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"), + ).toMatchObject({ status: "pass" }); + expect(appleHealthCalls).toBe(1); + }); + + test("fails SDK validation when a secondary supported platform is below its floor", async () => { + const root = await fixture({ complete: true }); + await makeMultiplatform(root, "13.5"); + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planMacOSNetworkCapability: async (options) => ({ + schemaVersion: 1, + kind: "clerk-macos-network-capability", + status: "satisfied", + root: options.root, + projectPath: options.projectPath, + targetId: options.targetId, + targetName: "MyApp", + files: [], + actions: [], + blockers: [], + }), + }), + ); + + const sdk = audit.results.find( + (result) => result.name === "iOS: Install Clerk's iOS SDK for the selected target", + ); + expect(sdk).toMatchObject({ status: "fail", message: expect.stringContaining("blocked") }); + expect(sdk?.detail).toContain("requires macOS 14.0 or newer"); + }); + + test("does not run the macOS network planner for a pure iOS target", async () => { + const root = await fixture({ complete: true }); + let networkPlanCalls = 0; + + await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planMacOSNetworkCapability: async (...args) => { + networkPlanCalls += 1; + return planMacOSNetworkCapability(...args); + }, + }), + ); + + expect(networkPlanCalls).toBe(0); + }); + test("uses semantic iOS checks instead of web environment checks", async () => { const root = await fixture(); const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); @@ -167,6 +458,228 @@ describe("runIOSDoctorChecks", () => { ); }); + test("uses native Clerk checks for a pure macOS application and skips Associated Domains", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + includeKey: false, + localSecrets: true, + macOSAppleEntitlement: false, + }); + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + expect(audit.inspection.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect( + audit.results.find( + (result) => result.name === "macOS: Install Clerk's Swift SDK for the selected target", + )?.status, + ).toBe("pass"); + expect( + audit.results.find( + (result) => result.name === "macOS: Configure Clerk with a publishable key", + )?.status, + ).toBe("pass"); + expect( + audit.results.find( + (result) => result.name === "macOS: Inject Clerk into the SwiftUI environment", + )?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "macOS: Add an authentication flow")?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "macOS: AuthView authentication methods") + ?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "macOS: Native Application")?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "macOS: Allow outgoing network access") + ?.status, + ).toBe("pass"); + expect(audit.results.some((result) => result.name.includes("associated domain"))).toBeFalse(); + expect(audit.results.some((result) => result.name.startsWith("iOS:"))).toBeFalse(); + expect(audit.results.some((result) => result.name === "Environment variables")).toBeFalse(); + }); + + test("fails locally without SDK or remote planning when platform evidence is unresolved", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + releasePlatform: "unresolved", + }); + let sdkPlanCalls = 0; + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSSDKInstall: async (...args) => { + sdkPlanCalls += 1; + return planIOSSDKInstall(...args); + }, + fetchApplication: async () => { + remoteCalls += 1; + throw new Error("remote inspection must not run"); + }, + }), + ); + + expect(audit.inspection.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect(audit.inspection.appTargets[0]?.platformEvidenceComplete).toBe(false); + expect(sdkPlanCalls).toBe(0); + expect(remoteCalls).toBe(0); + expect(audit.results.every((result) => result.status === "fail")).toBe(true); + expect(audit.results[0]?.detail).toContain("does not have one proven native platform"); + }); + + test("fails locally without SDK or remote planning for a Catalyst-enabled target", async () => { + const root = await fixture({ complete: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await readFile(projectPath, "utf8"); + await writeFile( + projectPath, + project.replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + 'SUPPORTS_MACCATALYST = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + ), + ); + let sdkPlanCalls = 0; + let remoteCalls = 0; + + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSSDKInstall: async (...args) => { + sdkPlanCalls += 1; + return planIOSSDKInstall(...args); + }, + fetchApplication: async () => { + remoteCalls += 1; + throw new Error("remote inspection must not run"); + }, + }), + ); + + expect(audit.inspection.appTargets[0]?.platformEvidenceComplete).toBe(false); + expect(sdkPlanCalls).toBe(0); + expect(remoteCalls).toBe(0); + expect(audit.results.every((result) => result.status === "fail")).toBe(true); + expect(audit.results[0]?.detail).toContain("also ships Mac Catalyst"); + }); + + test("reports missing macOS sandbox network access without changing the project", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + includeKey: false, + localSecrets: true, + macOSAppleEntitlement: false, + }); + await setMacOSNetworkEntitlement(root, "absent"); + const before = await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8"); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const result = audit.results.find( + (candidate) => candidate.name === "macOS: Allow outgoing network access", + ); + + expect(result).toMatchObject({ + status: "fail", + message: "Allow outgoing network access: setup required", + remedy: "Run `clerk init --target ` to safely complete this step.", + }); + expect(await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8")).toBe(before); + }); + + test("reports an explicit macOS network denial as manual Xcode work", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + includeKey: false, + localSecrets: true, + macOSAppleEntitlement: false, + }); + await setMacOSNetworkEntitlement(root, "false"); + + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + const result = audit.results.find( + (candidate) => candidate.name === "macOS: Allow outgoing network access", + ); + + expect(result?.status).toBe("fail"); + expect(result?.message).toBe("Allow outgoing network access: blocked"); + expect(result?.remedy).toContain("explicitly disables outgoing network access"); + }); + + test("fails remote inspection when the application payload is malformed", async () => { + const root = await fixture({ complete: true }); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchApplication: async () => { + throw new CliError("Unexpected application payload", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }, + }), + ); + + expect(audit.results.find((result) => result.name === "iOS: Native Application")).toMatchObject( + { + status: "fail", + message: "Native Application: Clerk returned an invalid remote response", + }, + ); + }); + + test("audits native Sign in with Apple for a pure macOS application", async () => { + const root = await fixture({ platform: "macos", macOSAppleEntitlement: false }); + await addAppleEntitlement(root); + let appleHealthCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + appleHealthCalls++; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", blockers: [] }, + }; + }, + }), + ); + + expect(appleHealthCalls).toBe(1); + expect( + audit.results.find((result) => result.name === "macOS: Sign in with Apple entitlement") + ?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "macOS: Clerk Sign in with Apple")?.status, + ).toBe("pass"); + expect(audit.results.some((result) => result.name.includes("associated domain"))).toBeFalse(); + }); + test("fails AuthView setup when the linked clerk-ios SDK is incompatible", async () => { const root = await fixture({ complete: true }); const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); @@ -203,6 +716,8 @@ describe("runIOSDoctorChecks", () => { root, projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", + supportedPlatforms: ["ios"], includeClerkKitUI: true, requirePrebuiltAuthCompatibility: true, }, @@ -256,6 +771,8 @@ struct MyApp: App { root, projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", + supportedPlatforms: ["ios"], }, ]); expect(await readFile(projectPath, "utf8")).toBe(duplicateProducts); @@ -295,6 +812,8 @@ struct MyApp: App { root, projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", + supportedPlatforms: ["ios"], includeClerkKitUI: true, }, ]); @@ -934,6 +1453,10 @@ struct ContentView: View { }, } as unknown as UserSettingsJSON; }, + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, auditIOSNativeAppleHealth: async () => { appleHealthCalls++; throw new Error("Apple health must wait for the local entitlement"); diff --git a/packages/cli-core/src/commands/doctor/ios.ts b/packages/cli-core/src/commands/doctor/ios.ts index 9922ad6dd..8d876dc79 100644 --- a/packages/cli-core/src/commands/doctor/ios.ts +++ b/packages/cli-core/src/commands/doctor/ios.ts @@ -16,9 +16,24 @@ import { auditIOSNativeAppleHealth } from "../init/ios/native-apple.ts"; import { buildIOSNativeReadinessAudit } from "../init/ios/native-readiness.ts"; import { auditIOSNativeRemoteSetup } from "../init/ios/native-remote.ts"; import { planIOSSDKInstall, type IOSSDKInstallPlan } from "../init/ios/install-sdk.ts"; +import { + planMacOSNetworkCapability, + type MacOSNetworkCapabilityPlan, +} from "../init/ios/macos-network.ts"; import { buildIOSSetupPlan } from "../init/ios/plan.ts"; +import { + inspectIOSPlatformViews, + iosPlatformViewsHaveAppleEntitlementIntent, + iosPlatformViewsHaveNativeAppleIntent, + type IOSPlatformViewsSnapshot, +} from "../init/ios/platform-views.ts"; import { hasSupportedIOSCustomConfigure } from "../init/ios/products.ts"; -import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStep } from "../init/ios/types.ts"; +import type { + IOSAppTarget, + IOSNativePlatform, + IOSProjectInspectionResult, + IOSSetupStep, +} from "../init/ios/types.ts"; import type { CheckResult, DoctorContext } from "./types.ts"; const LOCAL_STEP_REMEDY = "Run `clerk init --target ` to safely complete this step."; @@ -26,11 +41,26 @@ const AUTH_FLOW_REMEDY = "Integrate authentication at the app's intended signed-out entry point without replacing existing application UI: present ClerkKitUI's `AuthView`, or build a custom ClerkKit sign-in/sign-up flow."; const REMOTE_REMEDY = "Run `clerk init --target ` to preview and apply the missing Native Application setup."; -const ASSOCIATED_DOMAIN_RESULT_NAME = "iOS: Add Clerk's associated domain"; +const IOS_ASSOCIATED_DOMAIN_RESULT_NAME = "iOS: Add Clerk's associated domain"; + +function platformLabel(platform: IOSNativePlatform): "iOS" | "macOS" { + return platform === "macos" ? "macOS" : "iOS"; +} + +function doctorStepTitle(step: IOSSetupStep, platform: IOSNativePlatform): string { + if (platform === "ios") return step.title; + if (step.id === "enable-macos-network") return "Allow outgoing network access"; + return step.title + .replace("iOS application target", "macOS application target") + .replace("Clerk's iOS SDK", "Clerk's Swift SDK") + .replace("the iOS app", "the macOS app"); +} export interface IOSDoctorOptions { root: string; target?: string; + /** A same-run semantic inspection prepared by Doctor's framework router. */ + preparedInspection?: IOSProjectInspectionResult; } export interface IOSDoctorDependencies { @@ -46,6 +76,7 @@ export interface IOSDoctorDependencies { planIOSAppleEntitlement: typeof planIOSAppleEntitlement; auditIOSNativeAppleHealth: typeof auditIOSNativeAppleHealth; planIOSSDKInstall: typeof planIOSSDKInstall; + planMacOSNetworkCapability: typeof planMacOSNetworkCapability; } const defaultDependencies: IOSDoctorDependencies = { @@ -58,6 +89,7 @@ const defaultDependencies: IOSDoctorDependencies = { planIOSAppleEntitlement, auditIOSNativeAppleHealth, planIOSSDKInstall, + planMacOSNetworkCapability, }; function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { @@ -72,6 +104,7 @@ async function authViewEnvironmentResult( target: IOSAppTarget, dependencies: IOSDoctorDependencies, options: { + root: string; configureStatus: IOSSetupStep["status"] | undefined; fapiHost?: string; customSource: boolean; @@ -80,7 +113,7 @@ async function authViewEnvironmentResult( ): Promise { if (target.swift.authViewReferences.length === 0) return undefined; - const name = "iOS: AuthView authentication methods"; + const name = `${platformLabel(target.platform)}: AuthView authentication methods`; if (options.configureStatus !== "satisfied" || !options.fapiHost) { const customUnlinked = options.customSource && !options.linked; return { @@ -117,10 +150,15 @@ async function authViewEnvironmentResult( } const entitlementIsComplete = - target.configurations.length > 0 && - target.configurations.every( - (configuration) => configuration.entitlements?.signInWithAppleState === "exact", - ); + ( + await dependencies.planIOSAppleEntitlement({ + root: options.root, + projectPath: target.projectPath, + targetId: target.id, + platform: target.platform, + supportedPlatforms: target.supportedPlatforms, + }) + ).status === "satisfied"; return entitlementIsComplete ? { name, @@ -158,27 +196,30 @@ async function authViewEnvironmentResult( } } -function localStepResult(step: IOSSetupStep): CheckResult { - const name = `iOS: ${step.title}`; +function localStepResult(step: IOSSetupStep, platform: IOSNativePlatform): CheckResult { + const title = doctorStepTitle(step, platform); + const name = `${platformLabel(platform)}: ${title}`; const remedy = step.id === "select-target" ? step.description - : step.id === "add-authentication-flow" - ? AUTH_FLOW_REMEDY - : LOCAL_STEP_REMEDY; + : step.id === "enable-macos-network" && step.status === "blocked" + ? step.description + : step.id === "add-authentication-flow" + ? AUTH_FLOW_REMEDY + : LOCAL_STEP_REMEDY; switch (step.status) { case "satisfied": return { name, status: "pass", - message: `${step.title}: configured`, + message: `${title}: configured`, detail: step.description, }; case "review": return { name, status: "warn", - message: `${step.title}: review needed`, + message: `${title}: review needed`, detail: step.description, remedy: step.description, }; @@ -186,7 +227,7 @@ function localStepResult(step: IOSSetupStep): CheckResult { return { name, status: "fail", - message: `${step.title}: setup required`, + message: `${title}: setup required`, detail: step.description, remedy, }; @@ -194,7 +235,7 @@ function localStepResult(step: IOSSetupStep): CheckResult { return { name, status: "fail", - message: `${step.title}: blocked`, + message: `${title}: blocked`, detail: step.description, remedy, }; @@ -204,18 +245,32 @@ function localStepResult(step: IOSSetupStep): CheckResult { function localResults( inspection: IOSProjectInspectionResult, sdkInstallPlan?: IOSSDKInstallPlan, + macOSNetworkCapabilityPlan?: MacOSNetworkCapabilityPlan, + platformViews?: IOSPlatformViewsSnapshot, + platformCompatibilityBlockers?: readonly string[], ): CheckResult[] { - const plan = buildIOSSetupPlan(inspection, { sdkInstallPlan }); + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + macOSNetworkCapabilityPlan, + productDecision: platformViews?.productDecision, + platformCompatibilityBlockers, + }); + const target = selectedTarget(inspection); + const platform = target?.platform ?? (inspection.platform === "macos" ? "macos" : "ios"); const results = plan.steps - .filter((step) => step.id !== "register-native-application" || step.status === "blocked") - .map(localStepResult); - const readiness = buildIOSNativeReadinessAudit(inspection); + .filter( + (step) => + (step.id !== "register-native-application" || step.status === "blocked") && + (platform !== "macos" || step.id !== "add-associated-domain"), + ) + .map((step) => localStepResult(step, step.id === "enable-macos-network" ? "macos" : platform)); + const readiness = buildIOSNativeReadinessAudit(inspection, { platformViews }); if ( readiness.target.status === "selected" && readiness.target.appIdPrefix.status === "conflicting" ) { results.push({ - name: "iOS: App ID Prefix evidence", + name: `${platformLabel(platform)}: App ID Prefix evidence`, status: "fail", message: "App ID Prefix evidence: conflicting values were found", detail: @@ -230,24 +285,21 @@ function localResults( async function appleEntitlementResult( inspection: IOSProjectInspectionResult, target: IOSAppTarget, + platformViews: IOSPlatformViewsSnapshot, dependencies: IOSDoctorDependencies, ): Promise { - const hasCustomAppleIntent = target.swift.appleAuthReferences.length > 0; - const anyAppleEntitlement = target.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); - if (!hasCustomAppleIntent && !anyAppleEntitlement) return undefined; + if (!iosPlatformViewsHaveNativeAppleIntent(platformViews)) return undefined; const plan = await dependencies.planIOSAppleEntitlement({ root: inspection.root, projectPath: target.projectPath, targetId: target.id, + platform: target.platform, + supportedPlatforms: target.supportedPlatforms, }); if (plan.status === "satisfied") { return { - name: "iOS: Sign in with Apple entitlement", + name: `${platformLabel(target.platform)}: Sign in with Apple entitlement`, status: "pass", message: "Sign in with Apple entitlement: configured", detail: "Every selected-target configuration has the exact native Apple entitlement.", @@ -259,7 +311,7 @@ async function appleEntitlementResult( ? plan.actions.join("\n") : plan.blockers.map((blocker) => blocker.message).join("\n"); return { - name: "iOS: Sign in with Apple entitlement", + name: `${platformLabel(target.platform)}: Sign in with Apple entitlement`, status: "fail", message: "Sign in with Apple entitlement: incomplete", ...(detail ? { detail } : {}), @@ -273,7 +325,8 @@ function linkedDevelopmentKeyResult( application: Application, developmentInstanceId: string, ): CheckResult { - const name = "iOS: Linked development key"; + const target = selectedTarget(inspection); + const name = `${platformLabel(target?.platform ?? "ios")}: Linked development key`; const localPublishableKey = inspection.localPublishableKey; if (localPublishableKey.state !== "valid") { return { @@ -331,8 +384,9 @@ function linkedDevelopmentKeyResult( function linkedCustomApplicationResult( application: Application, developmentInstanceId: string, + platform: IOSNativePlatform, ): { result: CheckResult; fapiHost?: string } { - const name = "iOS: Linked Clerk application"; + const name = `${platformLabel(platform)}: Linked Clerk application`; const instance = application.instances.find( (candidate) => candidate.instance_id === developmentInstanceId, ); @@ -395,14 +449,14 @@ function linkedCustomAssociatedDomainResult( ); return configured ? { - name: ASSOCIATED_DOMAIN_RESULT_NAME, + name: IOS_ASSOCIATED_DOMAIN_RESULT_NAME, status: "pass", message: "Clerk's associated domain: matches the linked application", detail: "The custom publishable-key value was not inspected; this verifies the entitlements against the explicitly linked application.", } : { - name: ASSOCIATED_DOMAIN_RESULT_NAME, + name: IOS_ASSOCIATED_DOMAIN_RESULT_NAME, status: "fail", message: "Clerk's associated domain: does not match the linked application", detail: @@ -416,9 +470,13 @@ async function remoteResults( ctx: DoctorContext, inspection: IOSProjectInspectionResult, dependencies: IOSDoctorDependencies, + platformViews?: IOSPlatformViewsSnapshot, ): Promise { - const readiness = buildIOSNativeReadinessAudit(inspection); + const readiness = buildIOSNativeReadinessAudit(inspection, { platformViews }); const target = selectedTarget(inspection); + const platform = target?.platform ?? (inspection.platform === "macos" ? "macos" : "ios"); + const nativeApplicationName = `${platformLabel(platform)}: Native Application`; + const appleResultName = `${platformLabel(platform)}: Clerk Sign in with Apple`; const configureStep = buildIOSSetupPlan(inspection).steps.find( (step) => step.id === "configure-publishable-key", ); @@ -443,6 +501,7 @@ async function remoteResults( if (!profile) { if (target) { const authView = await authViewEnvironmentResult(target, dependencies, { + root: inspection.root, configureStatus: configureStep?.status, customSource, linked: false, @@ -452,7 +511,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "warn", message: "Native Application: remote state not inspected (project is not linked)", remedy: customSource @@ -466,9 +525,9 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "warn", - message: "Native Application: remote state not inspected (select one iOS target)", + message: `Native Application: remote state not inspected (select one ${platformLabel(platform)} target)`, remedy: "Rerun with `clerk doctor --target `.", }, ]; @@ -490,7 +549,7 @@ async function remoteResults( ), ]); const customApplication = customSource - ? linkedCustomApplicationResult(application, instanceId) + ? linkedCustomApplicationResult(application, instanceId, platform) : undefined; const linkedResult = customApplication?.result ?? @@ -505,6 +564,7 @@ async function remoteResults( : undefined); if (target) { const authView = await authViewEnvironmentResult(target, dependencies, { + root: inspection.root, configureStatus: configureStep?.status, fapiHost: verifiedFapiHost, customSource, @@ -513,7 +573,7 @@ async function remoteResults( if (authView) preliminaryResults.push(authView); } const customAssociatedDomain = - target && customApplication?.fapiHost + platform === "ios" && target && customApplication?.fapiHost ? linkedCustomAssociatedDomainResult(target, customApplication.fapiHost) : undefined; const results = [ @@ -523,9 +583,9 @@ async function remoteResults( ]; if (remotePlan.status === "satisfied") { results.push({ - name: "iOS: Native Application", + name: nativeApplicationName, status: "pass", - message: "Native API and iOS registration: configured", + message: `Native API and ${platformLabel(platform)} registration: configured`, detail: remotePlan.bundleIdentifier ? `Bundle ID: ${remotePlan.bundleIdentifier}` : undefined, @@ -536,26 +596,30 @@ async function remoteResults( ? remotePlan.actions.join("\n") : remotePlan.blockers.map((blocker) => blocker.message).join("\n"); results.push({ - name: "iOS: Native Application", + name: nativeApplicationName, status: "fail", message: remotePlan.status === "ready" - ? "Native API or iOS registration: setup required" - : "Native API or iOS registration: blocked", + ? `Native API or ${platformLabel(platform)} registration: setup required` + : `Native API or ${platformLabel(platform)} registration: blocked`, ...(detail ? { detail } : {}), remedy: REMOTE_REMEDY, }); } const bundleIdentifier = readiness.target.bundleIdentifier; - const hasAppleEntitlement = target?.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); - const hasCustomAppleIntent = (target?.swift.appleAuthReferences.length ?? 0) > 0; + const hasAppleEntitlement = platformViews + ? iosPlatformViewsHaveAppleEntitlementIntent(platformViews) + : target?.configurations.some( + (configuration) => + configuration.entitlements != null && + configuration.entitlements.signInWithAppleState !== "absent", + ) === true; + const hasNativeAppleIntent = platformViews + ? iosPlatformViewsHaveNativeAppleIntent(platformViews) + : hasAppleEntitlement || (target?.swift.appleAuthReferences.length ?? 0) > 0; if ( - (hasAppleEntitlement || hasCustomAppleIntent) && + hasNativeAppleIntent && bundleIdentifier.status === "resolved" && remotePlan.registration === "satisfied" ) { @@ -567,11 +631,12 @@ async function remoteResults( const apple = await dependencies.auditIOSNativeAppleHealth({ applicationId, instanceId, + platform, bundleIdentifier: registeredBundleIdentifier, }); if (apple.runtime.status === "satisfied") { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "pass", message: "Clerk Sign in with Apple: configured for the selected Bundle ID", detail: @@ -585,7 +650,7 @@ async function remoteResults( .map((blocker) => blocker.message) .join("\n"); results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "fail", message: apple.runtime.bundleIdentifierConfiguration === "required" @@ -606,7 +671,7 @@ async function remoteResults( }); } else { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "fail", message: "Clerk Sign in with Apple: configuration conflict", detail: apple.runtime.blockers.map((blocker) => blocker.message).join("\n"), @@ -616,7 +681,7 @@ async function remoteResults( } catch (error) { if (error instanceof PlapiError && error.status === 403) { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "fail", message: "Clerk Sign in with Apple: application access is not permitted", remedy: @@ -624,21 +689,21 @@ async function remoteResults( }); } else if (isAuthError(error) || (error instanceof PlapiError && error.status === 401)) { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "fail", message: "Clerk Sign in with Apple: Clerk authentication is invalid", remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", }); } else if (error instanceof PlapiError && error.status === 404) { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "fail", message: "Clerk Sign in with Apple: the linked app or instance was not found", remedy: "Run `clerk link` to refresh this project's application and instance IDs.", }); } else { results.push({ - name: "iOS: Clerk Sign in with Apple", + name: appleResultName, status: "warn", message: "Clerk Sign in with Apple: remote state could not be inspected", remedy: "Check your Clerk authentication and network connection, then rerun doctor.", @@ -652,7 +717,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "fail", message: "Native Application: Clerk returned an invalid remote response", remedy: @@ -664,7 +729,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "fail", message: "Native Application: application access is not permitted", remedy: @@ -676,7 +741,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "fail", message: "Native Application: Clerk authentication is invalid", remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", @@ -687,7 +752,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "fail", message: "Native Application: the linked app or development instance was not found", remedy: "Run `clerk link` to refresh this project's application and instance IDs.", @@ -697,7 +762,7 @@ async function remoteResults( return [ ...preliminaryResults, { - name: "iOS: Native Application", + name: nativeApplicationName, status: "warn", message: "Native Application: remote state could not be inspected", remedy: @@ -712,31 +777,68 @@ export async function runIOSDoctorChecks( options: IOSDoctorOptions, dependencies: IOSDoctorDependencies = defaultDependencies, ): Promise<{ inspection: IOSProjectInspectionResult; results: CheckResult[] }> { - const inspection = await dependencies.inspectIOSProject(options.root, { - target: options.target, - exhaustiveContainerDiscovery: true, - }); + const inspection = + options.preparedInspection ?? + (await dependencies.inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + })); const target = selectedTarget(inspection); - const requiresAuthViewCompatibility = (target?.swift.authViewReferences.length ?? 0) > 0; - const requiresClerkKitUI = - (target?.swift.importsClerkKitUI.length ?? 0) > 0 || requiresAuthViewCompatibility; - const sdkInstallPlan = target - ? await dependencies.planIOSSDKInstall({ - root: inspection.root, - projectPath: target.projectPath, - targetId: target.id, - ...(requiresClerkKitUI ? { includeClerkKitUI: true } : {}), - ...(requiresAuthViewCompatibility ? { requirePrebuiltAuthCompatibility: true } : {}), - }) + const platformViewsAudit = target + ? await inspectIOSPlatformViews(inspection, dependencies.inspectIOSProject) : undefined; - const results = localResults(inspection, sdkInstallPlan); + const platformViews = + platformViewsAudit?.status === "ready" ? platformViewsAudit.snapshot : undefined; + const platformCompatibilityBlockers = + platformViewsAudit?.status === "blocked" + ? platformViewsAudit.blockers.map((blocker) => blocker.message) + : undefined; + const requiresAuthViewCompatibility = platformViews?.requiresAuthViewCompatibility === true; + const requiresClerkKitUI = platformViews?.requiresClerkKitUI === true; + const sdkInstallPlan = + target?.platformEvidenceComplete && platformViews + ? await dependencies.planIOSSDKInstall({ + root: inspection.root, + projectPath: target.projectPath, + targetId: target.id, + platform: target.platform, + supportedPlatforms: target.supportedPlatforms, + ...(requiresClerkKitUI ? { includeClerkKitUI: true } : {}), + ...(requiresAuthViewCompatibility ? { requirePrebuiltAuthCompatibility: true } : {}), + }) + : undefined; + const macOSNetworkCapabilityPlan = + platformViews && target?.supportedPlatforms.includes("macos") + ? await dependencies.planMacOSNetworkCapability({ + root: inspection.root, + projectPath: target.projectPath, + targetId: target.id, + allowMissingEntitlementsCreation: true, + }) + : undefined; + const results = localResults( + inspection, + sdkInstallPlan, + macOSNetworkCapabilityPlan, + platformViews, + platformCompatibilityBlockers, + ); + if (platformViewsAudit?.status === "blocked") { + return { inspection, results }; + } + if (target && !target.platformEvidenceComplete) { + return { inspection, results }; + } if (target) { - const apple = await appleEntitlementResult(inspection, target, dependencies); + const apple = + platformViews && + (await appleEntitlementResult(inspection, target, platformViews, dependencies)); if (apple) results.splice(Math.max(0, results.length - 1), 0, apple); } - for (const remoteResult of await remoteResults(ctx, inspection, dependencies)) { + if (target && !platformViews) return { inspection, results }; + for (const remoteResult of await remoteResults(ctx, inspection, dependencies, platformViews)) { const existingIndex = - remoteResult.name === ASSOCIATED_DOMAIN_RESULT_NAME + remoteResult.name === IOS_ASSOCIATED_DOMAIN_RESULT_NAME ? results.findIndex((result) => result.name === remoteResult.name) : -1; if (existingIndex === -1) { diff --git a/packages/cli-core/src/commands/doctor/types.ts b/packages/cli-core/src/commands/doctor/types.ts index fc953378b..238e8f81c 100644 --- a/packages/cli-core/src/commands/doctor/types.ts +++ b/packages/cli-core/src/commands/doctor/types.ts @@ -78,6 +78,6 @@ export interface DoctorOptions { json?: boolean; spotlight?: boolean; fix?: boolean; - /** Exact Xcode application target name or PBX object ID. */ + /** Exact iOS or macOS application target name or PBX object ID. */ target?: string; } diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 9d0c0b472..e51a31401 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -40,58 +40,62 @@ clerk init --dry-run --target MyApp --json | `--login` | Force the authenticated flow: log in (interactively if needed) and link a real application instead of accountless keys. Errors in agent mode when unauthenticated (agents can't run OAuth) | | `--template ` | Pre-configure the accountless application at creation: `b2b-saas`, `b2c-saas`, `native`, `waitlist`. Only applies when the run resolves to accountless — errors otherwise (see [Application templates](#application-templates)); cannot be combined with `--login` | | `--fresh` | Replace an existing unclaimed accountless application with a new one, instead of keeping it (see [Accountless breadcrumb](#accountless-breadcrumb)). Only applies when the run resolves to accountless — errors otherwise; cannot be combined with `--login` | -| `--dry-run` | Inspect an existing native iOS project and print a semantic Clerk setup plan without changing local or remote state | +| `--dry-run` | Inspect an existing native Apple (iOS or macOS) project and print a semantic Clerk setup plan without changing local or remote state | | `--json` | Emit the `--dry-run` inspection and setup plan as structured JSON. Implied in agent mode; requires `--dry-run` | -| `--target ` | Select an iOS application target by target name or PBX object ID for either inspection or setup | -| `--allow-dirty` | Allow iOS setup to update a planned local file that already has changes. Existing bytes still participate in stale-plan and atomic-write validation | -| `--app-id-prefix ` | Apple App ID Prefix to use if the selected iOS Bundle ID needs a new Clerk registration. Never inferred from `DEVELOPMENT_TEAM`; required in agent mode when local/remote evidence cannot supply it | -| `--sign-in-with-apple` | Opt into native Sign in with Apple for the selected iOS target. Adds the exact Apple entitlement and enables the matching native Clerk connection; never requests hosted/web Apple credentials | +| `--target ` | Select a native Apple application target by target name or PBX object ID for either inspection or setup | +| `--allow-dirty` | Allow native Apple setup to update a planned local file that already has changes. Existing bytes still participate in stale-plan and atomic-write validation | +| `--app-id-prefix ` | Apple App ID Prefix to use if the selected native Apple Bundle ID needs a new Clerk registration. Never inferred from `DEVELOPMENT_TEAM`; required in agent mode when local/remote evidence cannot supply it | +| `--sign-in-with-apple` | Opt into native Sign in with Apple for the selected native Apple target. Adds the exact Apple entitlement and enables the matching native Clerk connection; never requests hosted/web Apple credentials | | `--prebuilt-auth-ui` | Opt into ClerkKitUI's prebuilt authentication UI for an untouched, safely inspectable SwiftUI starter. Existing or customized application UI is preserved and returned for review instead of being rewritten | | `-y, --yes` | Skip y/n confirmation prompts only. It neither forces nor bypasses accountless — the strategy is picked by auth state, mode, and flags. It does **not** replace an existing unclaimed accountless app — that still requires `--fresh` | | `--no-skills` | Skip the optional agent skills install prompt at the end of init | `--keyless` remains accepted as a deprecated, hidden compatibility alias for `--accountless`. Use `--accountless` in all new commands and documentation. -## Read-only iOS inspection +## Read-only native Apple inspection -`clerk init --dry-run` takes a separate, read-only path for existing native iOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, and entitlements. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. +`clerk init --dry-run` takes a separate, read-only path for existing native iOS and macOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, and entitlements. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. + +Automatic mutation currently supports targets whose shipping platforms are all iOS or macOS and that do not explicitly enable Mac Catalyst. A target that also ships visionOS—including Xcode's standard Multiplatform App template—or sets `SUPPORTS_MACCATALYST=YES` is still inspected and receives a blocked plan explaining the boundary, but normal `clerk init` applies no new Clerk setup changes and performs no remote writes. Use a non-Catalyst iOS/macOS target for automatic setup or configure the target manually. Publishable-key inspection intentionally has a narrow boundary. One literal passed directly to `Clerk.configure(publishableKey:)` in the selected app's startup initializer can be validated with its value redacted. Every other expression is classified as custom: the CLI preserves it without reading its backing file, scheme, environment, or value. The command does not authenticate, call Clerk APIs, run Xcode, resolve packages, send command telemetry, check for CLI updates, or write project/global CLI files. Publishable key values are never included in output. Flags that imply project creation or already-known remote application state (`--starter`, `--app`, `--app-id-prefix`, `--accountless`, `--login`, `--template`, and `--fresh`) are rejected before inspection. The deprecated `--keyless` alias is rejected identically. `--sign-in-with-apple` is allowed because dry-run previews only the local entitlement; it reports the Clerk connection as not inspected until a regular authenticated run. -When multiple iOS application targets are present, the plan is `blocked` until one is selected with `--target `. A blocked plan still exits successfully because the inspection completed; automation should branch on the JSON `status` field. +When multiple native Apple application targets are present, the plan is `blocked` until one is selected with `--target `. A blocked plan still exits successfully because the inspection completed; automation should branch on the JSON `status` field. -## Native iOS local setup +## Native Apple local setup -For a native iOS project, normal `clerk init` re-runs the semantic inspection, builds the complete local plan, previews it with the publishable key redacted, and asks for consent before authentication or local writes. It reuses an existing verified local or remote clerk-ios package when possible; otherwise it adds the official `https://github.com/clerk/clerk-ios` Swift package. For an untouched Clerk integration, it links both `ClerkKit` and `ClerkKitUI` to the exact selected application target so the optional prebuilt `AuthView` path is available. Existing source-proven custom-flow projects remain `ClerkKit`-only unless their source or Xcode graph already requires `ClerkKitUI`. +For a native iOS or macOS project, normal `clerk init` re-runs the semantic inspection, builds the complete local plan, previews it with the publishable key redacted, and asks for consent before authentication or local writes. It reuses an existing verified local or remote clerk-ios package when possible; otherwise it adds the official `https://github.com/clerk/clerk-ios` Swift package. For an untouched Clerk integration, it links both `ClerkKit` and `ClerkKitUI` to the exact selected application target so the optional prebuilt `AuthView` path is available. Existing source-proven custom-flow projects remain `ClerkKit`-only unless their source or Xcode graph already requires `ClerkKitUI`. -For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. +For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching Clerk's Swift setup. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. An existing custom `Clerk.configure(...)` source is never migrated or rewritten. The developer must explicitly select the existing Clerk application it belongs to; agents do this with `--app `. That choice authorizes linked-app and Native Application setup, but the CLI does not inspect the custom value or claim that it matches the selected application. -The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. +The 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. -When every selected-target build configuration already points to a readable, target-exclusive XML entitlements file, `clerk init` can add the exact bare `webcredentials:` value to all of those files. When every configuration is missing entitlements and the selected target has exactly one exclusive filesystem-synchronized source root, it can instead create a minimal `/.entitlements` file and attach it with iPhone-device and iPhone-simulator-qualified build settings. Those qualified settings do not affect macOS, visionOS, or other platforms in a multiplatform target. Existing files preserve unrelated entitlements, comments, newline style, and file modes, and all eligible entitlement changes commit in the same stale-input and rollback-aware transaction as the SDK and direct Swift edits. A `?mode=developer` entry is preserved but does not replace the bare entry. Mixed or conflicting entitlements paths, classic or shared destination ambiguity, generated projects, unresolved build settings, malformed or binary plists, and paths outside the invocation root remain review steps. +For iOS targets only, when every selected-target build configuration already points to a readable, target-exclusive XML entitlements file, `clerk init` can add the exact bare `webcredentials:` value to all of those files. When every configuration is missing entitlements and the selected target has exactly one exclusive filesystem-synchronized source root, it can instead create a minimal `/.entitlements` file and attach it with iPhone-device and iPhone-simulator-qualified build settings. The CLI creates these qualified settings only after proving that every shipping target platform is iOS or macOS; a target that also ships visionOS is blocked before entitlement mutation. Existing files preserve unrelated entitlements, comments, newline style, and file modes, and all eligible entitlement changes commit in the same stale-input and rollback-aware transaction as the SDK and direct Swift edits. A `?mode=developer` entry is preserved but does not replace the bare entry. Mixed or conflicting entitlements paths, classic or shared destination ambiguity, generated projects, unresolved build settings, malformed or binary plists, and paths outside the invocation root remain review steps. + +For macOS targets, `clerk init` verifies that an app using App Sandbox can make outgoing network connections. When the target can be updated safely, it enables the `com.apple.security.network.client` entitlement in every active macOS entitlements file, or creates and attaches a macOS entitlements file when the target has one exclusive filesystem-synchronized source root. An unsandboxed target requires no capability change. Conflicting, malformed, mixed, or unresolved sandbox and entitlements settings remain review steps. -The read-only output also includes a native-readiness section for the Bundle ID, literal App ID Prefix evidence, and local Associated Domains coverage. Because `--dry-run` is strictly local-only, remote Native API and iOS registration state is reported as `not-inspected`. A regular authenticated run audits those resources through the Platform API after the local preview. +The read-only output also includes a native-readiness section for the Bundle ID and literal App ID Prefix evidence, plus local Associated Domains coverage on iOS or App Sandbox outgoing-network coverage on macOS. Because `--dry-run` is strictly local-only, remote Native API and native application registration state is reported as `not-inspected`. A regular authenticated run audits those resources through the Platform API after the local preview. -If the linked development instance needs remote changes, `clerk init` prints a second, exact plan and asks separately before making them. Existing registrations are never updated or deleted. When a registration is missing, the CLI uses a consistently proven literal App ID Prefix, an explicit `--app-id-prefix`, or a human-entered value. If every selected-target configuration has the same valid `DEVELOPMENT_TEAM`, human mode offers it as a clearly labeled, unverified suggestion and lets the user enter a different prefix; it is never treated as proven evidence or selected non-interactively. Conflicting local evidence or an existing registration with a different prefix blocks before local files are committed. After consent, the guarded local transaction commits first, remote state is re-read, the exact iOS registration is created, Native API is enabled last, and both resources are verified. Remote retries are additive and idempotent: if a remote step fails after local commit, local changes remain and rerunning safely reconciles the remaining work. +If the linked development instance needs remote changes, `clerk init` prints a second, exact plan and asks separately before making them. Existing registrations are never updated or deleted. When a registration is missing, the CLI uses a consistently proven literal App ID Prefix, an explicit `--app-id-prefix`, or a human-entered value. If every selected-target configuration has the same valid `DEVELOPMENT_TEAM`, human mode offers it as a clearly labeled, unverified suggestion and lets the user enter a different prefix; it is never treated as proven evidence or selected non-interactively. Conflicting local evidence or an existing registration with a different prefix blocks before local files are committed. After consent, the guarded local transaction commits first, remote state is re-read, the exact native Apple registration is created, Native API is enabled last, and both resources are verified. Remote retries are additive and idempotent: if a remote step fails after local commit, local changes remain and rerunning safely reconciles the remaining work. -Native Sign in with Apple is an explicit opt-in, either through the human prompt or `--sign-in-with-apple`; `--yes` alone never enables it. The local transaction adds only `com.apple.developer.applesignin = ["Default"]` to every proven selected-target entitlements route. After the exact iOS registration and Native API are ready, the CLI enables the Apple connection for that exact Bundle ID and verifies the final config. It neither asks for nor changes an Apple Services ID, Team ID, Key ID, or private key. Existing hosted Apple fields are preserved. With ClerkKitUI, `AuthView` displays Apple automatically; a custom flow can call `try await Clerk.shared.auth.signInWithApple()`. +Native Sign in with Apple is an explicit opt-in, either through the human prompt or `--sign-in-with-apple`; `--yes` alone never enables it. The local transaction adds only `com.apple.developer.applesignin = ["Default"]` to every proven selected-target entitlements route. After the exact native Apple registration and Native API are ready, the CLI enables the Apple connection for that exact Bundle ID and verifies the final config. It neither asks for nor changes an Apple Services ID, Team ID, Key ID, or private key. Existing hosted Apple fields are preserved. With ClerkKitUI, `AuthView` displays Apple automatically; a custom flow can call `try await Clerk.shared.auth.signInWithApple()`. -The prebuilt authentication UI is also an explicit, independent opt-in. `--yes`, agent mode, ClerkKitUI linkage, and `--sign-in-with-apple` never select it by themselves. `--prebuilt-auth-ui` can rewrite only the exact untouched SwiftUI starter screen owned by the selected target; existing navigation, state, custom authentication, partial ClerkKitUI integrations, and established application content are preserved and reported as a review step. The generated screen matches the documented native-components quickstart: a `UserButton` signed-out entry presents `AuthView` in a sheet and prefetches Clerk images. It does not gate or replace established application content. Clerk's native components require iOS 17 and the modern ClerkKit/ClerkKitUI products available in clerk-ios 1.0.0 or newer. Before committing an opted-in UI, the authenticated run also inspects the linked Frontend API environment without printing its publishable key; when Apple is already enabled and authenticatable, the same pre-authorized local transaction verifies or adds the required Apple entitlement without changing the remote Apple strategy. +The prebuilt authentication UI is also an explicit, independent opt-in. `--yes`, agent mode, ClerkKitUI linkage, and `--sign-in-with-apple` never select it by themselves. `--prebuilt-auth-ui` can rewrite only the exact untouched SwiftUI starter screen owned by the selected target; existing navigation, state, custom authentication, partial ClerkKitUI integrations, and established application content are preserved and reported as a review step. The generated screen matches the documented native-components quickstart: a `UserButton` signed-out entry presents `AuthView` in a sheet and prefetches Clerk images. It does not gate or replace established application content. Clerk's native components require iOS 17 or macOS 14 and the modern ClerkKit/ClerkKitUI products available in clerk-ios 1.0.0 or newer. Before committing an opted-in UI, the authenticated run also inspects the linked Frontend API environment without printing its publishable key; when Apple is already enabled and authenticatable, the same pre-authorized local transaction verifies or adds the required Apple entitlement without changing the remote Apple strategy. ## Agent Mode When running in agent mode (`--mode agent` or non-TTY), the command runs the full init flow non-interactively: -- Confirmation prompts are generally auto-skipped, but changing a native iOS Xcode project requires an explicit `--yes` -- Native iOS remote mutations also require explicit `--yes`; when no existing registration or complete literal evidence supplies the App ID Prefix, pass `--app-id-prefix` +- Confirmation prompts are generally auto-skipped, but changing a native Apple Xcode project requires an explicit `--yes` +- Native Apple remote mutations also require explicit `--yes`; when no existing registration or complete literal evidence supplies the App ID Prefix, pass `--app-id-prefix` - Native Sign in with Apple additionally requires `--sign-in-with-apple`; `--yes` grants mutation consent but never opts a project into an authentication strategy -- The prebuilt iOS authentication UI additionally requires `--prebuilt-auth-ui`; `--yes` and agent mode never opt into replacing even an eligible starter screen +- The prebuilt native Apple authentication UI additionally requires `--prebuilt-auth-ui`; `--yes` and agent mode never opt into replacing even an eligible starter screen - `init --dry-run` automatically emits structured JSON, even when `--json` is omitted - For **existing projects**: framework and package manager are auto-detected, no flags required - For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided @@ -99,23 +103,23 @@ When running in agent mode (`--mode agent` or non-TTY), the command runs the ful - For accountless-capable frameworks with no `--app` and no linked profile: - When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. - When **unauthenticated**, init uses accountless: the app runs on auto-generated dev keys, and init writes a legacy-named `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically. -- For frameworks that require API keys, agent mode normally requires `--app ` or an existing link. A safely inspectable fresh iOS target is the exception: with valid credentials and explicit `--yes`, init can create and link the development application needed by the approved direct-source plan +- For frameworks that require API keys, agent mode normally requires `--app ` or an existing link. A safely inspectable fresh native Apple target is the exception: with valid credentials and explicit `--yes`, init can create and link the development application needed by the approved direct-source plan - `--login` while unauthenticated exits with a usage error (agents can't complete the interactive browser login) -- Agent mode never trusts the mere _presence_ of a credential before native iOS mutation. A Platform API key is validated with a read-only application-list request, and a stored OAuth session must still resolve to a user. Invalid credentials stop native iOS setup before local apply. Elsewhere, a broken credential is treated as unauthenticated, which routes an accountless-capable framework to accountless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login +- Agent mode never trusts the mere _presence_ of a credential before native Apple mutation. A Platform API key is validated with a read-only application-list request, and a stored OAuth session must still resolve to a user. Invalid credentials stop native Apple setup before local apply. Elsewhere, a broken credential is treated as unauthenticated, which routes an accountless-capable framework to accountless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login - Agent mode never mints a fresh accountless application over an existing unclaimed one on re-run — see [Accountless breadcrumb](#accountless-breadcrumb) ## Flow -`--dry-run` first detects an existing native iOS project, performs the read-only inspection described above, prints its setup plan, and returns before authentication, linking, SDK installation, scaffolding, or any other setup work. +`--dry-run` first detects an existing native Apple project, performs the read-only inspection described above, prints its setup plan, and returns before authentication, linking, SDK installation, scaffolding, or any other setup work. The normal setup flow is: 1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) -2. **Native iOS only**: validates iOS-specific flags, resolves the current local Clerk profile, inspects the selected target, and previews the complete redacted SDK plus Swift/runtime configuration plan. It obtains one aggregate consent but writes nothing. Agent credentials may be validated with a read-only API call before this preview so an invalid non-interactive invocation cannot proceed; interactive login, application selection/creation, key fetching, and every local write remain after consent +2. **Native Apple only**: validates native Apple-specific flags, resolves the current local Clerk profile, inspects the selected iOS or macOS target, and previews the complete redacted SDK plus Swift/runtime configuration plan. It obtains one aggregate consent but writes nothing. Agent credentials may be validated with a read-only API call before this preview so an invalid non-interactive invocation cannot proceed; interactive login, application selection/creation, key fetching, and every local write remain after consent 3. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a Platform API key accepted by a read-only PLAPI request, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: - **`--accountless`**: forces accountless mode, even when logged in. Only valid on an accountless-capable framework, and cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a legacy-named `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically - **`--login`**: forces the authenticated flow. In agent mode while unauthenticated (or while stored credentials are broken) this exits with a usage error, since agents can't complete the interactive browser login - - **Real app target** (`--app`, linked profile, or an approved fresh iOS direct-source plan): authenticates and links if needed, then configures the native runtime directly or pulls API keys for frameworks that consume an env file + - **Real app target** (`--app`, linked profile, or an approved fresh native Apple direct-source plan): authenticates and links if needed, then configures the native runtime directly or pulls API keys for frameworks that consume an env file - **Agent + non-accountless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - **Agent + accountless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` - **Agent + accountless-capable framework + unauthenticated + no real app target**: uses accountless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" @@ -123,7 +127,7 @@ The normal setup flow is: - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't accountless — see [Application templates](#application-templates) and [Accountless breadcrumb](#accountless-breadcrumb) 4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links or creates/selects the project application via `clerk link` -5. **Eligible native iOS only**: resolves the explicitly selected application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. Custom key sources are preserved without inspection. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read +5. **Eligible native Apple only**: resolves the explicitly selected application by its exact ID, fetches only its public development key, and audits Native API, native application registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one platform-qualified entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button, and enabling outgoing network access for a sandboxed macOS app. Custom key sources are preserved without inspection. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read 6. Displays detected framework and variant 7. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance 8. Installs the appropriate Clerk SDK (skips if already present) @@ -134,7 +138,7 @@ The normal setup flow is: 13. Runs project formatters (Prettier/Biome) on generated files 14. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls 15. Prints a summary of created, modified, and skipped files with recommendations -16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native iOS leaves custom key storage unchanged +16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native Apple projects leave custom key storage unchanged 17. **Accountless mode** (unauthenticated runs whose resolved strategy in step 3 is accountless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints an accountless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed accountless app already exists for this project (see [Re-running init on an already-accountless project](#re-running-init-on-an-already-accountless-project)), in which case the existing keys are kept and reported instead 18. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) @@ -158,12 +162,12 @@ Detects the project's framework from `package.json` dependencies (checked top-to Native mobile platforms may not have a `package.json`, so they are detected from project marker files when no npm framework matches: -| Marker files | Framework | Clerk SDK | Publishable Key Env Var | -| ------------------------------------------------------------------- | ---------------- | ------------------------------------------------- | ----------------------- | -| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` + `ClerkKitUI` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | -| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | +| Marker files | Framework | Clerk SDK | Publishable Key Env Var | +| ------------------------------------------------------------------- | -------------------- | ------------------------------------------------- | ----------------------- | +| `*.xcodeproj` / `*.xcworkspace` | iOS or macOS (Swift) | `ClerkKit` + `ClerkKitUI` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | +| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | -A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For iOS, init can edit the selected target's Swift Package Manager graph directly. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing custom `Clerk.configure(...)` sources are preserved without interpreting how they load a key. Android still prints the Gradle installation steps. +A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For native Apple projects, init semantically identifies the selected iOS or macOS target and can edit its Swift Package Manager graph directly. The shared explicit framework selector remains `--framework ios`; target inspection determines the actual Apple platform. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing custom `Clerk.configure(...)` sources are preserved without interpreting how they load a key. Android still prints the Gradle installation steps. The **Accountless** column indicates whether the framework's Clerk SDK supports accountless mode (auto-generated temporary dev keys). Accountless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--accountless` forces accountless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on an accountless-capable framework creates a real app named after the project and links it. @@ -171,7 +175,7 @@ Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `ya ## Scaffolding -Scaffolding is supported for every detected framework. The dedicated iOS preflight may safely update the selected Xcode target's Swift package graph and direct configuration before generic scaffolding; custom key sources are preserved and require explicit application selection. Remaining iOS work and all Android native setup are printed as post-instructions. +Scaffolding is supported for every detected framework. The dedicated native Apple preflight may safely update the selected iOS or macOS Xcode target's Swift package graph, direct configuration, and platform-specific capabilities before generic scaffolding; custom key sources are preserved and require explicit application selection. Remaining native Apple work and all Android native setup are printed as post-instructions. All scaffolding is idempotent — files are skipped if they already contain Clerk setup. @@ -288,9 +292,9 @@ A post-instruction reminds the user that `types/globals.d.ts` must be covered by Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./frameworks/node-server.ts). The entry file is resolved from `package.json#main` (ignored when it points at build output like `dist/`) and common candidates (`[src/]index|server|app|main` with `.ts/.mts/.js/.mjs/.cjs`, ordered by basename so an unrelated `src/app.ts` can't outrank a root `index.js`). The resolved path is the one named in the `--env-file` post-instruction. Both ESM (`import`) and CommonJS (`require`, including the inline `require("fastify")(...)` form) are supported; injection lands after the full creation statement, so multi-line options objects and chained calls (e.g. `.withTypeProvider()`) are safe. When no entry or creation call is found, a post-instruction with the quickstart link is printed instead. -### iOS (Swift) / Android (Kotlin) +### Native Apple (iOS/macOS Swift) / Android (Kotlin) -For iOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; custom configuration sources remain unchanged and require explicit application selection. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. Safe XML entitlements files can receive the selected application's exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. The authenticated phase then audits and, with separate consent, additively creates the exact iOS registration and enables Native API for the selected development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. +For iOS and macOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; custom configuration sources remain unchanged and require explicit application selection. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. On iOS only, safe XML entitlements files can receive the selected application's exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. On macOS, setup verifies the App Sandbox outgoing-network capability and can add `com.apple.security.network.client` safely when required. The authenticated phase then audits and, with separate consent, additively creates the exact native Apple registration and enables Native API for the selected development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. ## Agent skills install diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 33ab974a1..99b5c5568 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 directConfig from "../ios/direct-config.ts"; const temporaryRoots: string[] = []; const emptyRoot = await mkdtemp(join(tmpdir(), "clerk-ios-framework-empty-")); @@ -47,6 +48,7 @@ function makeCtx(): ProjectContext { test("matches only the ios framework", () => { const ctx = makeCtx(); + expect(ios.name).toBe("Native Apple (Swift)"); expect(ios.matches(ctx)).toBe(true); expect(ios.matches({ ...ctx, framework: { ...ctx.framework, dep: "android" } })).toBe(false); }); @@ -115,6 +117,42 @@ test("defers the Associated Domain host to ready direct configuration", async () } }); +test("uses the selected macOS platform for final planning and guidance", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-framework-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete: false, clerkSDK: false, platform: "macos" }); + const directPlanner = spyOn(directConfig, "planIOSDirectConfig"); + const domainPlanner = spyOn(associatedDomain, "planIOSAssociatedDomain"); + + try { + const plan = await ios.scaffold({ + ...makeCtx(), + cwd: root, + iosTarget: "MyApp", + framework: { ...makeCtx().framework, name: "macOS (Swift)" }, + }); + + expect(directPlanner).toHaveBeenCalledWith( + expect.objectContaining({ root, platform: "macos" }), + ); + expect(domainPlanner).toHaveBeenCalledWith( + expect.objectContaining({ root, platform: "macos" }), + ); + + const instructions = plan.postInstructions.join("\n"); + expect(instructions).toContain("Clerk Swift SDK"); + expect(instructions).toContain("register your macOS app"); + expect(instructions).toContain("Clerk Swift SDK guide: https://github.com/clerk/clerk-ios"); + expect(instructions).not.toContain("Clerk iOS SDK"); + expect(instructions).not.toContain("register your iOS app"); + expect(instructions).not.toContain("docs/ios/getting-started/quickstart"); + expect(instructions).not.toContain("Associated Domains"); + } finally { + directPlanner.mockRestore(); + domainPlanner.mockRestore(); + } +}); + test("omits manual Native Applications guidance after authenticated remote verification", async () => { const plan = await ios.scaffold({ ...makeCtx(), iosNativeRemoteReady: true }); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 84e0b808b..4162dab06 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -3,9 +3,9 @@ import { inspectIOSProject } from "../ios/inspect.ts"; import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "../ios/local-plan.ts"; /** - * iOS (Swift) support for `clerk init`. + * Native Apple (Swift) support for `clerk init`. * - * The Clerk iOS SDK ships via Swift Package Manager and the publishable key is + * The Clerk Swift SDK ships via Swift Package Manager and the publishable key is * configured in Swift source (`Clerk.configure(publishableKey:)`), not an env * file. The dedicated iOS apply phase safely handles the selected target's SPM * product linkage before this scaffolder runs. For a safely inspectable fresh @@ -16,12 +16,13 @@ import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "../ios/l * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ export const ios: FrameworkScaffold = { - name: "iOS (Swift)", + name: "Native Apple (Swift)", dep: "ios", matches: (ctx) => ctx.framework.dep === "ios", async scaffold(ctx: ProjectContext): Promise { + // Rebuild the aggregate proposal from post-apply state so guidance describes remaining work. const inspection = await inspectIOSProject(ctx.cwd, { target: ctx.iosTarget, exhaustiveContainerDiscovery: true, @@ -34,10 +35,33 @@ export const ios: FrameworkScaffold = { }); const selection = inspection.selection; const target = proposal.selectedTarget; + const platform = + proposal.platform ?? + (ctx.framework.name === "macOS (Swift)" + ? "macos" + : ctx.framework.name === "iOS (Swift)" + ? "ios" + : undefined); + const platformLabel = + platform === "macos" ? "macOS" : platform === "ios" ? "iOS" : "native Apple"; + const sdkLabel = platform === "ios" ? "Clerk iOS SDK" : "Clerk Swift SDK"; const productDecision = proposal.productDecision ?? "prebuilt"; const includeClerkKitUI = productDecision === "prebuilt"; const hasCustomConfigure = proposal.hasSupportedCustomConfigure; const setupPlan = proposal.setupPlan; + const platformCompatibilityBlockers = proposal.platformCompatibilityBlockers; + if (platformCompatibilityBlockers.length > 0) { + return { + actions: [], + postInstructions: [ + ...platformCompatibilityBlockers, + "Automatic setup stopped before using one platform's Swift setup or Bundle ID for the whole target. Make the supported-platform setup consistent, then rerun clerk init.", + platform === "ios" + ? "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart" + : "Clerk Swift SDK guide: https://github.com/clerk/clerk-ios", + ], + }; + } const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); const needsAttention = (id: string) => { const setupStep = setupPlan.steps.find((step) => step.id === id); @@ -64,10 +88,10 @@ export const ios: FrameworkScaffold = { ] : includeClerkKitUI ? [ - "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit and ClerkKitUI for the fastest prebuilt AuthView path)", + `Add the ${sdkLabel} via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit and ClerkKitUI for the fastest prebuilt AuthView path)`, ] : [ - "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit for this existing custom-flow path)", + `Add the ${sdkLabel} via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit for this existing custom-flow path)`, ]; const requiresSwiftUIEnvironment = target != null && (target.swift.environmentConsumers.length > 0 || includeClerkKitUI); @@ -85,7 +109,7 @@ export const ios: FrameworkScaffold = { const registrationInstructions = !ctx.iosNativeRemoteReady && needsAttention("register-native-application") ? [ - "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", + `Enable the Native API and register your ${platformLabel} app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications`, ] : []; const domainInstructions = needsAttention("add-associated-domain") @@ -122,6 +146,10 @@ export const ios: FrameworkScaffold = { : "Native Sign in with Apple is ready; AuthView displays Apple automatically, while custom flows can call `try await Clerk.shared.auth.signInWithApple()`", ] : []; + const setupGuideInstruction = + platform === "ios" + ? "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart" + : "Clerk Swift SDK guide: https://github.com/clerk/clerk-ios"; return { actions: [], postInstructions: [ @@ -132,7 +160,7 @@ export const ios: FrameworkScaffold = { ...nativeAppleInstructions, ...authFlowInstructions, ...environmentInstructions, - "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart", + setupGuideInstruction, ], }; }, 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 f24a872b7..92a487626 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -20,9 +20,11 @@ import { nativeRemoteMod, nativeAppleMod, iosDevelopmentKeyMod, + iosPlatformViewsMod, plapiMod, fapiMod, FAKE_IOS_NATIVE_READINESS, + FAKE_IOS_PLATFORM_VIEWS, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; import { getLogLevel, setLogLevel } from "../../lib/log.ts"; @@ -61,6 +63,7 @@ function iosRemotePlan(overrides: Partial = {}): IOSNativeR status: "ready", applicationId: "app_test", instanceId: "ins_test", + platform: "ios", bundleIdentifier: "com.example.MyApp", appIdPrefix: "LEGACY1234", nativeApi: "required", @@ -122,6 +125,7 @@ function iosPrebuiltAuthPlan(overrides: Partial = {}): IOSP root: "/tmp/test", projectPath: "MyApp.xcodeproj", targetId: "TARGET", + platform: "ios", allowDirty: false, appSourcePath: "MyApp/MyAppApp.swift", expectedAppSourceHash: "app-hash", @@ -136,6 +140,9 @@ function iosPrebuiltAuthPlan(overrides: Partial = {}): IOSP function iosSetupResult(overrides: Partial = {}): IOSLocalSetupResult { return { targetName: "MyApp", + platform: "ios", + supportedPlatforms: ["ios"], + platformViews: FAKE_IOS_PLATFORM_VIEWS, setupPlan: { schemaVersion: 1, kind: "clerk-ios-setup", @@ -146,6 +153,7 @@ function iosSetupResult(overrides: Partial = {}): IOSLocalS targetId: "TARGET", targetName: "MyApp", projectPath: "MyApp.xcodeproj", + platform: "ios", }, summary: { satisfied: 0, required: 0, review: 0, blocked: 0 }, steps: [], @@ -180,12 +188,39 @@ describe("init iOS", () => { return () => stage.mock.calls.map((call) => call[0]); } + test("labels the selected macOS target before final scaffolding", async () => { + const { captured } = setup({ email: "test@test.com" }); + const ctx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(ctx); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + platform: "macos", + nativeReadiness: { + ...FAKE_IOS_NATIVE_READINESS, + target: selectedNativeTarget({ platform: "macos" }), + }, + }), + ); + + await init({ yes: true }); + + expect(ctx.framework.name).toBe("macOS (Swift)"); + expect(scaffoldMod.scaffold).toHaveBeenCalledWith( + expect.objectContaining({ + framework: expect.objectContaining({ name: "macOS (Swift)" }), + }), + ); + expect(captured.err).toContain("Detected"); + expect(captured.err).toContain("macOS (Swift)"); + expect(captured.err).not.toContain("iOS (Swift)"); + }); + test("rejects iOS-only apply flags for a non-iOS project before authentication", async () => { setup({ email: "test@test.com" }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); await expect(init({ target: "MyApp" })).rejects.toThrow( - "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native Apple projects", ); expect(loginMod.login).not.toHaveBeenCalled(); @@ -264,7 +299,7 @@ describe("init iOS", () => { spyOn(context, "gatherContext").mockResolvedValue(nativeIOSContext()); await expect(init({ yes: true })).rejects.toThrow( - "Native iOS setup in agent mode requires valid Clerk authentication", + "Native Apple setup in agent mode requires valid Clerk authentication", ); expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); @@ -481,7 +516,7 @@ describe("init iOS", () => { spyOn(frameworkMod, "lookupFramework").mockReturnValue(FAKE_CTX.framework); await expect(init({ framework: "next", target: "MyApp" })).rejects.toThrow( - "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native Apple projects", ); expect(context.gatherContext).not.toHaveBeenCalled(); @@ -493,7 +528,7 @@ describe("init iOS", () => { spyOn(context, "gatherContext").mockResolvedValue(null); await expect(init({ target: "MyApp" })).rejects.toThrow( - "Could not detect an existing native iOS project", + "Could not detect an existing native Apple project", ); expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); @@ -503,7 +538,7 @@ describe("init iOS", () => { setup(); await expect(init({ starter: true, target: "MyApp" })).rejects.toThrow( - "require an existing native iOS project", + "require an existing native Apple project", ); expect(context.gatherContext).not.toHaveBeenCalled(); @@ -511,8 +546,8 @@ describe("init iOS", () => { }); test.each([ - [{ accountless: true }, "--accountless is not supported for iOS"], - [{ keyless: true }, "--accountless is not supported for iOS"], + [{ accountless: true }, "--accountless is not supported for native Apple projects"], + [{ keyless: true }, "--accountless is not supported for native Apple projects"], [{ template: "native" as const }, "--template only applies to accountless applications"], [{ fresh: true }, "--fresh only applies to accountless applications"], ])("rejects iOS-incompatible flags before Xcode apply", async (flags, message) => { @@ -755,7 +790,7 @@ describe("init iOS", () => { } as never); await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( - "AuthView methods changed while the approved iOS setup was being prepared", + "AuthView methods changed while the approved native Apple setup was being prepared", ); expect(environment).toHaveBeenCalledTimes(2); @@ -1021,7 +1056,10 @@ describe("init iOS", () => { yes: true, }); expect(commitLocal).toHaveBeenCalledWith(setupResult, undefined); - expect(applyRemote).toHaveBeenCalledWith(expect.objectContaining({ status: "ready" })); + expect(applyRemote).toHaveBeenCalledWith( + expect.objectContaining({ status: "ready" }), + expect.objectContaining({ revalidateLocalPreconditions: expect.any(Function) }), + ); expect(resolveKeys.mock.invocationCallOrder[0]).toBeLessThan( prepareRemote.mock.invocationCallOrder[0]!, ); @@ -1079,6 +1117,7 @@ describe("init iOS", () => { expect(prepareApple).toHaveBeenCalledWith({ applicationId: "app_test", instanceId: "ins_test", + platform: "ios", bundleIdentifier: "com.Example.MyApp", nativeApplicationReady: true, requested: true, @@ -1272,6 +1311,45 @@ describe("init iOS", () => { expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); }); + test("does not mutate native state when a secondary platform identity changes during local commit", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + spyOn(iosPlatformViewsMod, "reinspectIOSPlatformViews").mockResolvedValue({ + status: "ready", + snapshot: { + ...FAKE_IOS_PLATFORM_VIEWS, + bundleIdentifier: "com.example.changed", + }, + }); + const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + + await expect(init({ yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining( + "Local changes remain intact, but no Clerk Native Application changes were made", + ), + }); + + expect(commitLocal).toHaveBeenCalledTimes(1); + expect(applyRemote).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + test("reports partial remote failure without claiming the local setup was rolled back", async () => { const { captured } = setup({ email: "test@test.com" }); const stages = trackStages(); @@ -1343,7 +1421,7 @@ describe("init iOS", () => { }); await expect(init({ yes: true })).rejects.toThrow( - "linked Clerk application changed while its iOS publishable key was being resolved", + "linked Clerk application changed while its native publishable key was being resolved", ); expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledTimes(1); @@ -1391,7 +1469,7 @@ describe("init iOS", () => { }); await expect(init({ yes: true })).rejects.toThrow( - "local Clerk application link changed before the approved iOS setup", + "local Clerk application link changed before the approved native Apple setup", ); expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index b78b2f8f6..0d7d72214 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -92,17 +92,17 @@ type InitOptions = { template?: KeylessTemplate; /** Replace an existing unclaimed accountless application instead of keeping it. */ fresh?: boolean; - /** Inspect an iOS project and print the setup plan without changing local or remote state. */ + /** Inspect a native Apple project and print the setup plan without changing local or remote state. */ dryRun?: boolean; - /** Emit the read-only iOS inspection and setup plan as JSON. */ + /** Emit the read-only native Apple inspection and setup plan as JSON. */ json?: boolean; - /** iOS application target name or PBX object ID. */ + /** Native Apple application target name or PBX object ID. */ target?: string; - /** Allow an iOS apply action to update a project file that already has local changes. */ + /** Allow native Apple setup to update a project file that already has local changes. */ allowDirty?: boolean; - /** Apple App ID Prefix used when a new Clerk iOS registration is required. */ + /** Apple App ID Prefix used when a new Clerk native application registration is required. */ appIdPrefix?: string; - /** Opt into native Sign in with Apple setup for the selected iOS target. */ + /** Opt into native Sign in with Apple setup for the selected native Apple target. */ signInWithApple?: boolean; /** Opt into ClerkKitUI's prebuilt AuthView flow for a proven pristine SwiftUI target. */ prebuiltAuthUI?: boolean; @@ -149,7 +149,7 @@ export async function init(options: InitOptions = {}) { options.prebuiltAuthUI === true; if (requiresExistingIOSProject && frameworkOverride && frameworkOverride.dep !== "ios") { throwUsageError( - "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects.", + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native Apple projects.", ); } @@ -191,7 +191,7 @@ export async function init(options: InitOptions = {}) { options.prebuiltAuthUI) ) { throwUsageError( - "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects.", + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native Apple projects.", ); } if (ctx.framework.dep === "ios") { @@ -202,7 +202,7 @@ export async function init(options: InitOptions = {}) { if (options.dryRun) { if (ctx.framework.dep !== "ios") { throwUsageError( - `--dry-run currently supports native iOS projects only; detected ${ctx.framework.name}.`, + `--dry-run currently supports native Apple projects only; detected ${ctx.framework.name}.`, ); } await runAppleNativeDryRun({ @@ -231,6 +231,10 @@ export async function init(options: InitOptions = {}) { validateAgentAuthentication, }); validatedAgentAuthLabel = appleNativeSetup.validatedAgentAuthLabel; + ctx.framework = { + ...ctx.framework, + name: appleNativeSetup.frameworkName, + }; } await enrichProjectContext(ctx); @@ -344,7 +348,7 @@ export async function init(options: InitOptions = {}) { authenticatedKeysHandled, }); - // Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with. + // Native platforms (Apple/Android) have no npx/Node toolchain to run `skills add` with. if (options.skills !== false && isNpmFramework(ctx.framework)) { setTelemetryStage("skills"); bar(); @@ -398,7 +402,7 @@ function assertUsableFlags(options: InitOptions, accountless: boolean): void { options.prebuiltAuthUI) ) { throwUsageError( - "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui require an existing native iOS project and cannot be combined with --starter.", + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui require an existing native Apple project and cannot be combined with --starter.", ); } if ( @@ -430,24 +434,24 @@ function assertUsableFlags(options: InitOptions, accountless: boolean): void { } /** - * Rejects accountless-only flags before the iOS apply phase. Native iOS does not - * consume Clerk's accountless bootstrap, so letting strategy resolution reject + * Rejects accountless-only flags before the native Apple apply phase. Native Apple projects do + * not consume Clerk's accountless bootstrap, so letting strategy resolution reject * these later could otherwise modify the Xcode project before a usage error. */ function assertIOSUsableFlags(options: InitOptions): void { if (options.accountless || options.keyless) { throwUsageError( - "--accountless is not supported for iOS (Swift). Run `clerk auth login` and use `clerk init --app ` instead.", + "--accountless is not supported for native Apple projects. Run `clerk auth login` and use `clerk init --app ` instead.", ); } if (options.template) { throwUsageError( - "--template only applies to accountless applications, but iOS (Swift) does not support accountless mode. Drop --template.", + "--template only applies to accountless applications, but native Apple projects do not support accountless mode. Drop --template.", ); } if (options.fresh) { throwUsageError( - "--fresh only applies to accountless applications, but iOS (Swift) does not support accountless mode. Drop --fresh.", + "--fresh only applies to accountless applications, but native Apple projects do not support accountless mode. Drop --fresh.", ); } } @@ -613,7 +617,7 @@ async function resolveExistingProjectContext( ); if (!ctx) { throw new CliError( - "Could not detect an existing native iOS project. --target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui never bootstrap a new project.", + "Could not detect an existing native Apple project. --target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui never bootstrap a new project.", { code: ERROR_CODE.FRAMEWORK_UNDETECTED }, ); } @@ -1022,16 +1026,25 @@ export function registerInit(program: Program): void { ) .option( "--dry-run", - "Inspect an existing iOS project and print a setup plan without changing local or remote state", + "Inspect an existing Xcode project and print an iOS/macOS setup plan without changing local or remote state", + ) + .option("--json", "Output the read-only native Apple inspection and setup plan as JSON") + .option( + "--target ", + "Select a native Apple application target by name or PBX object ID", + ) + .option( + "--allow-dirty", + "Allow a native Apple project file with existing local changes to be updated", ) - .option("--json", "Output the read-only iOS inspection and setup plan as JSON") - .option("--target ", "Select an iOS application target by name or PBX object ID") - .option("--allow-dirty", "Allow an iOS project file with existing local changes to be updated") .option( "--app-id-prefix ", - "10-character Apple App ID Prefix to use when Clerk needs to register the selected iOS Bundle ID", + "10-character Apple App ID Prefix to use when Clerk needs to register the selected Bundle ID", + ) + .option( + "--sign-in-with-apple", + "Enable native Sign in with Apple for the selected native Apple target", ) - .option("--sign-in-with-apple", "Enable native Sign in with Apple for the selected iOS target") .option( "--prebuilt-auth-ui", "Add ClerkKitUI's prebuilt AuthView flow to a proven pristine SwiftUI target", @@ -1077,11 +1090,11 @@ export function registerInit(program: Program): void { }, { command: "clerk init --dry-run", - description: "Inspect an iOS project and print its setup plan without changes", + description: "Inspect a native Apple project and print its setup plan without changes", }, { command: "clerk init --dry-run --target MyApp --json", - description: "Inspect one iOS app target and emit a machine-readable plan", + description: "Inspect one native Apple app target and emit a machine-readable plan", }, { command: "clerk init -y", diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts index 00c350d77..df908b428 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -15,6 +15,7 @@ import { } from "./apple-entitlement.ts"; import { applyIOSFileTransaction } from "./file-transaction.ts"; import { + convertIOSFixtureToMultiplatform, convertIOSFixtureToSynchronizedMissingEntitlements, createIOSFixture, IOS_FIXTURE_IDS, @@ -79,6 +80,85 @@ afterEach(async () => { }); describe("iOS Sign in with Apple entitlement setup", () => { + test("applies and revalidates separate iOS and macOS entitlements", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.client`, + ); + await convertIOSFixtureToMultiplatform(root); + const options = { + ...planOptions(root), + platform: "ios" as const, + supportedPlatforms: ["ios", "macos"] as const, + }; + + const plan = await planIOSAppleEntitlement(options); + expect(plan).toMatchObject({ + status: "ready", + supportedPlatforms: ["ios", "macos"], + platformPlans: [ + { platform: "ios", status: "ready" }, + { platform: "macos", status: "ready" }, + ], + }); + expect(plan.files.map((file) => file.path)).toEqual([ + "MyApp/MyApp.entitlements", + "MyApp/MyApp.mac.entitlements", + ]); + + expect((await applyIOSAppleEntitlement(plan)).status).toBe("applied"); + for (const file of ["MyApp.entitlements", "MyApp.mac.entitlements"]) { + expect(await readFile(join(root, "MyApp", file), "utf8")).toContain(APPLE_KEY); + } + expect(await planIOSAppleEntitlement(options)).toMatchObject({ status: "satisfied" }); + }); + + test("deduplicates one entitlement shared by iOS and macOS", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "MyApp.mac.entitlements"), + ``, + ); + await convertIOSFixtureToMultiplatform(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await writeFile( + projectPath, + (await readFile(projectPath, "utf8")).replaceAll( + "MyApp/MyApp.mac.entitlements", + "MyApp/MyApp.entitlements", + ), + ); + const options = { + ...planOptions(root), + platform: "ios" as const, + supportedPlatforms: ["ios", "macos"] as const, + }; + + const plan = await planIOSAppleEntitlement(options); + expect(plan).toMatchObject({ status: "ready" }); + expect(plan.files).toHaveLength(1); + expect((await applyIOSAppleEntitlement(plan)).status).toBe("applied"); + expect(await planIOSAppleEntitlement(options)).toMatchObject({ status: "satisfied" }); + }); + + test("blocks every write when a secondary platform entitlement is malformed", async () => { + const root = await fixture(); + await writeFile(join(root, "MyApp", "MyApp.mac.entitlements"), "not a plist"); + await convertIOSFixtureToMultiplatform(root); + const before = await treeDigest(root); + const plan = await planIOSAppleEntitlement({ + ...planOptions(root), + platform: "ios", + supportedPlatforms: ["ios", "macos"], + }); + + expect(plan).toMatchObject({ status: "blocked" }); + expect(plan.blockers.some((item) => item.message.startsWith("macOS:"))).toBeTrue(); + expect((await applyIOSAppleEntitlement(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + }); + test("adds exactly Default while preserving comments, CRLF newlines, mode, and idempotence", async () => { const root = await fixture(); const path = join(root, "MyApp", "MyApp.entitlements"); 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 edb4020fe..4a60f2869 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -22,6 +22,7 @@ import { } from "./entitlements-settings.ts"; import { isRecord } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import type { IOSNativePlatform } from "./types.ts"; const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; const APPLE_SIGN_IN_VALUE = "Default"; @@ -51,6 +52,12 @@ export interface IOSAppleEntitlementPlan { root: string; projectPath: string; targetId: string; + /** Defaults to iOS for older serialized plans. */ + platform?: IOSNativePlatform; + /** Every platform covered by this plan. Omitted by older single-platform plans. */ + supportedPlatforms?: readonly IOSNativePlatform[]; + /** Platform-scoped plans retained for safe preparation and post-write validation. */ + platformPlans?: IOSAppleEntitlementPlan[]; targetName?: string; files: IOSAppleEntitlementPlanFile[]; /** PBX settings needed only when the target has no entitlements file yet. */ @@ -64,6 +71,10 @@ export interface IOSAppleEntitlementPlanOptions { /** Invocation-root-relative selected .xcodeproj path. */ projectPath: string; targetId: string; + /** Defaults to iOS. */ + platform?: IOSNativePlatform; + /** When provided, plan the entitlement for every supported target platform. */ + supportedPlatforms?: readonly IOSNativePlatform[]; /** Allows the strict synchronized-root planner to create and attach a new file. */ allowMissingEntitlementsCreation?: boolean; } @@ -123,6 +134,8 @@ function planBase(options: IOSAppleEntitlementPlanOptions) { root: resolve(options.root), projectPath: options.projectPath.replaceAll("\\", "/"), targetId: options.targetId, + platform: options.platform ?? "ios", + ...(options.supportedPlatforms ? { supportedPlatforms: [...options.supportedPlatforms] } : {}), }; } @@ -452,16 +465,22 @@ function candidateWithApple(root: string, document: EntitlementsDocument): Uint8 * Plans the exact native Sign in with Apple entitlement across every selected * target entitlements variant. No Apple or Clerk credentials are retained. */ -export async function planIOSAppleEntitlement( +async function planIOSAppleEntitlementForPlatform( options: IOSAppleEntitlementPlanOptions, ): Promise { - const normalized = { ...options, root: resolve(options.root) }; + const normalized = { + ...options, + root: resolve(options.root), + platform: options.platform ?? "ios", + }; const entitlementProbe = await planIOSAssociatedDomain({ root: normalized.root, projectPath: normalized.projectPath, targetId: normalized.targetId, + platform: normalized.platform, deferToPublishableKey: true, allowMissingEntitlementsCreation: normalized.allowMissingEntitlementsCreation, + allowSelectedTargetPlatformSharing: true, }); if (entitlementProbe.status === "blocked") { return blockedPlan( @@ -511,13 +530,157 @@ export async function planIOSAppleEntitlement( ? [] : [ files.some((file) => file.operation === "create") - ? "Create and attach an iOS entitlements file with the Sign in with Apple entitlement set to Default." - : "Set the Sign in with Apple entitlement to Default in every selected-target iOS entitlements configuration.", + ? `Create and attach a ${normalized.platform === "macos" ? "macOS" : "iOS"} entitlements file with the Sign in with Apple entitlement set to Default.` + : `Set the Sign in with Apple entitlement to Default in every selected-target ${normalized.platform === "macos" ? "macOS" : "iOS"} entitlements configuration.`, ], blockers: [], }; } +function orderedPlatforms(options: IOSAppleEntitlementPlanOptions): IOSNativePlatform[] { + const primary = options.platform ?? "ios"; + return [ + primary, + ...new Set((options.supportedPlatforms ?? [primary]).filter((item) => item !== primary)), + ]; +} + +function samePlanFile(left: IOSAppleEntitlementPlanFile, right: IOSAppleEntitlementPlanFile) { + return ( + left.path === right.path && + left.operation === right.operation && + left.expectedHash === right.expectedHash + ); +} + +/** Plans the native Apple entitlement for every supported platform of one target. */ +export async function planIOSAppleEntitlement( + options: IOSAppleEntitlementPlanOptions, +): Promise { + const platforms = orderedPlatforms(options); + if (platforms.length === 1) { + return planIOSAppleEntitlementForPlatform({ + ...options, + platform: platforms[0], + supportedPlatforms: undefined, + }); + } + + const platformPlans = await Promise.all( + platforms.map(async (platform) => + planIOSAppleEntitlementForPlatform({ + ...options, + platform, + supportedPlatforms: undefined, + }), + ), + ); + const filesByPath = new Map(); + for (const plan of platformPlans) { + for (const file of plan.files) { + const existing = filesByPath.get(file.path); + if (existing && !samePlanFile(existing, file)) { + return blockedPlan(options, [ + blocker( + "invalid-plan", + `${file.path} resolved inconsistently across the selected target's supported platforms.`, + ), + ]); + } + filesByPath.set(file.path, file); + } + } + const blocked = platformPlans.filter((plan) => plan.status === "blocked"); + const status = + blocked.length > 0 + ? "blocked" + : platformPlans.some((plan) => plan.status === "ready") + ? "ready" + : "satisfied"; + return { + ...planBase({ ...options, supportedPlatforms: platforms }), + status, + supportedPlatforms: platforms, + platformPlans, + ...(platformPlans.find((plan) => plan.targetName)?.targetName + ? { targetName: platformPlans.find((plan) => plan.targetName)?.targetName } + : {}), + files: [...filesByPath.values()], + ...(platformPlans.find((plan) => plan.missingEntitlementsSettings)?.missingEntitlementsSettings + ? { + missingEntitlementsSettings: platformPlans.find( + (plan) => plan.missingEntitlementsSettings, + )?.missingEntitlementsSettings, + } + : {}), + actions: platformPlans.flatMap((plan) => plan.actions), + blockers: blocked.flatMap((plan) => + plan.blockers.map((item) => ({ + ...item, + message: `${plan.platform === "macos" ? "macOS" : "iOS"}: ${item.message}`, + })), + ), + }; +} + +async function prepareMultiplatformAppleEntitlement( + plan: IOSAppleEntitlementPlan, + options: IOSAppleEntitlementPrepareOptions, +): Promise { + const platformPlans = plan.platformPlans ?? []; + if (platformPlans.length < 2 || platformPlans.some((item) => item.platformPlans != null)) { + return blockPrepared( + plan, + "invalid-plan", + "The multiplatform Apple entitlement plan is incomplete.", + ); + } + + const initial = [...(options.baseMutations ?? [])]; + const initialByPath = new Map(initial.map((mutation) => [resolve(mutation.path), mutation])); + let composed = initial; + let needsValidation = false; + for (const platformPlan of platformPlans) { + const prepared = await prepareIOSAppleEntitlementMutation(platformPlan, { + baseMutations: composed, + }); + if (prepared.status === "stale") return { status: "stale", plan }; + if (prepared.status === "blocked") { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: prepared.plan.blockers, + }, + }; + } + if (prepared.status === "satisfied") continue; + needsValidation = true; + const consumed = new Set(prepared.consumedBaseMutationPaths.map((path) => resolve(path))); + composed = [ + ...composed.filter((mutation) => !consumed.has(resolve(mutation.path))), + ...prepared.mutations, + ]; + } + if (!needsValidation) return { status: "satisfied", plan }; + + const mutations: IOSFileMutation[] = []; + const consumedBaseMutationPaths: string[] = []; + for (const mutation of composed) { + const path = resolve(mutation.path); + const original = initialByPath.get(path); + if (!original) { + mutations.push(mutation); + } else if (!isDeepStrictEqual(original, mutation)) { + mutations.push(mutation); + consumedBaseMutationPaths.push(path); + } + } + return preparedWithHiddenMutations(plan, mutations, consumedBaseMutationPaths); +} + export async function prepareIOSAppleEntitlementMutation( plan: IOSAppleEntitlementPlan, options: IOSAppleEntitlementPrepareOptions = {}, @@ -537,6 +700,7 @@ export async function prepareIOSAppleEntitlementMutation( "The serialized Apple entitlement plan is incomplete.", ); } + if (plan.platformPlans) return prepareMultiplatformAppleEntitlement(plan, options); const baseByPath = new Map(); for (const mutation of options.baseMutations ?? []) { @@ -590,6 +754,7 @@ export async function prepareIOSAppleEntitlementMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, }); if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; @@ -790,6 +955,8 @@ export async function validatePreparedIOSAppleEntitlement( root: prepared.plan.root, projectPath: prepared.plan.projectPath, targetId: prepared.plan.targetId, + platform: prepared.plan.platform, + supportedPlatforms: prepared.plan.supportedPlatforms, }); const expectedPaths = prepared.plan.files.map((file) => file.path).sort(); return ( diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts index 0420254f2..2e138cc11 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { cp, mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,9 +17,11 @@ import type { PbxObjects } from "./pbx.ts"; import { authFixtureKey, canonicalSwiftUIFixture, + cleanupApplyCLITestState, createIsolatedCLIState, createUnconfiguredFixture, developmentPublishableKey, + resetApplyCLITestRemoteState, runCLI, runCommand, temporaryDirectories, @@ -28,6 +30,9 @@ import { ERROR_CODE } from "../../../lib/errors.ts"; setDefaultTimeout(15_000); +beforeEach(resetApplyCLITestRemoteState); +afterEach(cleanupApplyCLITestState); + function runSchemeSource(key: string): string { return ``; } 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 05c9f4ae1..1e7046276 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 @@ -1,4 +1,3 @@ -import { afterAll, afterEach } from "bun:test"; import { cp, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -26,6 +25,9 @@ const authFixtureApp = { }; let nativeAPIEnabled = false; let nextIOSApplication = 1; +let nativeSettingsPatchCount = 0; +let iosApplicationPostCount = 0; +let appleConfigPatchCount = 0; let appleConfigVersion = "v1_1234abcd"; let appleConnection: Record = { enabled: false, @@ -62,6 +64,7 @@ const authServer = Bun.serve({ return Response.json({ object: "native_settings", api_enabled: nativeAPIEnabled }); } if (request.method === "PATCH") { + nativeSettingsPatchCount += 1; const body = (await request.json()) as { api_enabled?: boolean }; if (body.api_enabled !== true) return Response.json({ error: "invalid" }, { status: 422 }); nativeAPIEnabled = true; @@ -71,6 +74,7 @@ const authServer = Bun.serve({ if (url.pathname === `${nativeBase}/native_applications/ios`) { if (request.method === "GET") return Response.json(iosApplications); if (request.method === "POST") { + iosApplicationPostCount += 1; const body = (await request.json()) as { app_id_prefix: string; bundle_id: string }; const existing = iosApplications.find( (application) => @@ -118,6 +122,7 @@ const authServer = Bun.serve({ }); } if (request.method === "PATCH") { + appleConfigPatchCount += 1; const body = (await request.json()) as { connection_oauth_apple?: Record; }; @@ -142,17 +147,28 @@ const authServer = Bun.serve({ }, }); -afterAll(async () => authServer.stop(true)); +// This helper is shared by multiple test files in the same Bun test worker. Keep +// the fixture server available for the worker's lifetime without keeping the +// process alive; a file-scoped afterAll hook can otherwise stop it while another +// importing test file is still running. +authServer.unref(); -afterEach(async () => { - await Promise.all( - temporaryDirectories.splice(0).map(async (path) => rm(path, { recursive: true })), - ); +export function resetApplyCLITestRemoteState(): void { nativeAPIEnabled = false; nextIOSApplication = 1; + nativeSettingsPatchCount = 0; + iosApplicationPostCount = 0; + appleConfigPatchCount = 0; iosApplications.splice(0); resetAppleConfiguration({ enabled: false, authenticatable: true }); -}); +} + +export async function cleanupApplyCLITestState(): Promise { + await Promise.all( + temporaryDirectories.splice(0).map(async (path) => rm(path, { recursive: true })), + ); + resetApplyCLITestRemoteState(); +} export async function createIsolatedCLIState(): Promise { const configDir = await mkdtemp(join(tmpdir(), "clerk-ios-apply-config-")); @@ -276,3 +292,16 @@ export function resetAppleConfiguration(connection: Record): vo export function currentAppleConnection(): Record { return appleConnection; } + +export function currentNativeRemoteState() { + return { + application: structuredClone(authFixtureApp), + nativeAPIEnabled, + iosApplications: structuredClone(iosApplications), + mutations: { + nativeSettingsPatchCount, + iosApplicationPostCount, + appleConfigPatchCount, + }, + }; +} 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 01b589c32..afe4aa9a3 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 @@ -1,17 +1,25 @@ -import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { cp, mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { ERROR_CODE } from "../../../lib/errors.ts"; +import { runIOSDoctorChecks, type IOSDoctorDependencies } from "../../doctor/ios.ts"; +import type { DoctorContext } from "../../doctor/types.ts"; import { inspectIOSProject } from "./inspect.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; import { + addVisionOSDestinationsToFixture, + convertIOSFixtureToMultiplatform, + convertIOSFixtureToPlatformFilteredAppRoots, convertIOSFixtureToSynchronizedMissingEntitlements, + convertIOSFixtureToSynchronizedRoot, createIOSFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; +import { planIOSSDKInstall } from "./install-sdk.ts"; +import { planMacOSNetworkCapability } from "./macos-network.ts"; import * as prompts from "../../../lib/prompts.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import type { PbxObjects } from "./pbx.ts"; @@ -19,10 +27,13 @@ import { addStarterContentViewToFixture, authFixtureKey, canonicalSwiftUIFixture, + cleanupApplyCLITestState, createCustomFlowWithStarterContent, createIsolatedCLIState, createUnconfiguredFixture, currentAppleConnection, + currentNativeRemoteState, + resetApplyCLITestRemoteState, resetAppleConfiguration, runCLI, runCommand, @@ -31,9 +42,577 @@ import { setDefaultTimeout(15_000); +beforeEach(resetApplyCLITestRemoteState); +afterEach(cleanupApplyCLITestState); + +async function convertFixtureToUnsandboxedMultiplatform(root: string): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + await Bun.write( + projectPath, + project + .replaceAll("SDKROOT = iphoneos;", "SDKROOT = auto;") + .replaceAll( + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";', + 'SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";', + ) + .replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0;", + "IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 14.0; ENABLE_APP_SANDBOX = NO;", + ), + ); +} + +async function enableMacCatalystInFixture(root: string): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectPath).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + settings.SUPPORTS_MACCATALYST = "YES"; + settings["PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]"] = "com.example.MyApp.catalyst"; + } + await Bun.write(projectPath, buildPbxProject(project)); +} + +function doctorContext(): DoctorContext { + const noopFix = () => ({ label: "noop", run: async () => {} }); + return { + hasPlatformAPIKey: () => true, + hasAccountCredentials: async () => true, + verifyAccountAccess: async () => {}, + getToken: async () => "platform-token", + getValidToken: async () => "platform-token", + getProfile: async () => ({ + path: "fixture", + resolvedVia: "directory", + profile: { + workspaceId: "org_fixture", + appId: "app_ios_apply", + instances: { development: "ins_ios_apply_development" }, + }, + }), + getApplication: async () => null, + getKeylessTarget: async () => undefined, + getKeylessInstance: async () => null, + getKeylessKeyError: async () => undefined, + hasClaimBreadcrumb: async () => false, + fixes: { login: noopFix, link: noopFix, envPull: noopFix }, + }; +} + +async function auditCurrentNativeFixture(root: string) { + const remote = currentNativeRemoteState(); + const dependencies: IOSDoctorDependencies = { + inspectIOSProject, + fetchApplication: async () => remote.application, + getNativeSettings: async () => ({ + object: "native_settings", + api_enabled: remote.nativeAPIEnabled, + }), + listIOSApplications: async () => remote.iosApplications, + fetchUserSettings: async () => ({ social: {} }) as never, + auditIOSPrebuiltAuthEnvironment: () => { + throw new Error("AuthView environment inspection is not expected for this fixture"); + }, + planIOSAppleEntitlement: async () => { + throw new Error("Apple entitlement inspection is not expected for this fixture"); + }, + auditIOSNativeAppleHealth: async () => { + throw new Error("Native Apple inspection is not expected for this fixture"); + }, + planIOSSDKInstall, + planMacOSNetworkCapability, + }; + return runIOSDoctorChecks(doctorContext(), { root, target: "MyApp" }, dependencies); +} + +function expectAutomatedDoctorChecksToPass( + results: Awaited>["results"], +): void { + for (const expectedName of [ + "iOS: Install Clerk's iOS SDK for the selected target", + "iOS: Configure Clerk with a publishable key", + "iOS: Inject Clerk into the SwiftUI environment", + "iOS: Add Clerk's associated domain", + "macOS: Allow outgoing network access", + "iOS: Linked development key", + "iOS: Native Application", + ]) { + expect(results.find((result) => result.name === expectedName)).toMatchObject({ + status: "pass", + }); + } + expect( + results.filter( + (result) => result.status === "fail" && !result.name.includes("authentication flow"), + ), + ).toEqual([]); + expect(results.find((result) => result.name.includes("authentication flow"))).toMatchObject({ + status: "fail", + }); +} + +async function linkedProductFilters(root: string, productName: "ClerkKit" | "ClerkKitUI") { + const project = parsePbxProject( + await Bun.file(join(root, "MyApp.xcodeproj", "project.pbxproj")).text(), + ) as unknown as { objects: PbxObjects }; + const target = project.objects[IOS_FIXTURE_IDS.appTarget]!; + const productIds = (target.packageProductDependencies as string[]).filter( + (id) => project.objects[id]?.productName === productName, + ); + expect(productIds).toHaveLength(1); + const frameworkPhaseId = (target.buildPhases as string[]).find( + (id) => project.objects[id]?.isa === "PBXFrameworksBuildPhase", + ); + expect(frameworkPhaseId).toBeDefined(); + return ((project.objects[frameworkPhaseId!]!.files as string[]) ?? []) + .map((id) => project.objects[id]!) + .filter((object) => productIds.includes(String(object.productRef))) + .map((object) => object.platformFilter as string | undefined) + .sort((left, right) => String(left).localeCompare(String(right))); +} + +async function expectSeparatePlatformEntitlements(root: string): Promise { + const project = parsePbxProject( + await Bun.file(join(root, "MyApp.xcodeproj", "project.pbxproj")).text(), + ) as unknown as { objects: PbxObjects }; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = project.objects[id]!.buildSettings as Record; + expect(settings.CODE_SIGN_ENTITLEMENTS).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBe("MyApp/MyApp.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBe( + "MyApp/MyApp.entitlements", + ); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.mac.entitlements"); + } +} + describe("clerk init iOS SDK apply", () => { const captured = useCaptureLog(); + test("dry-run includes the macOS network step for a primary-iOS multiplatform target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await convertFixtureToUnsandboxedMultiplatform(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--dry-run", "--target", "MyApp"], + configDir, + ); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.exitCode).toBe(0); + expect(output).toContain("Select the iOS application target"); + expect(output).toContain("Allow outgoing network access for macOS"); + }); + + test("dry-run detects Apple entitlement intent from the secondary macOS view", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-apple-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await Bun.write( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.clientcom.apple.developer.applesigninDefault`, + ); + await convertIOSFixtureToMultiplatform(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--dry-run", "--target", "MyApp"], + configDir, + ); + const output = `${result.stdout}\n${result.stderr}`; + + expect(result.exitCode).toBe(0); + expect(output).toContain("Enable native Sign in with Apple"); + expect(output).toContain("iOS"); + }); + + test("plans macOS network readiness for a primary-iOS multiplatform target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-local-setup-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await convertFixtureToUnsandboxedMultiplatform(root); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: false, + prebuiltAuthUI: false, + signInWithApple: false, + }); + + expect(setup).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios", "macos"], + macOSNetworkCapabilityPlan: { status: "satisfied" }, + }); + expect(await treeDigest(root)).toEqual(before); + }); + + test("plans explicit Apple capability for every multiplatform target platform", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-apple-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.client`, + ); + await convertIOSFixtureToMultiplatform(root); + + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: true, + }); + + expect(setup.appleEntitlementPlan).toMatchObject({ + status: "ready", + supportedPlatforms: ["ios", "macos"], + platformPlans: [ + { platform: "ios", status: "ready" }, + { platform: "macos", status: "ready" }, + ], + }); + + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + for (const file of ["MyApp.entitlements", "MyApp.mac.entitlements"]) { + expect(await Bun.file(join(root, "MyApp", file)).text()).toContain( + "com.apple.developer.applesignin", + ); + } + + const applied = await treeDigest(root); + const rerun = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: true, + }); + expect(rerun.appleEntitlementPlan).toMatchObject({ status: "satisfied" }); + await applyIOSPlannedLocalSetup(rerun, authFixtureKey); + expect(await treeDigest(root)).toEqual(applied); + }); + + test("keeps fresh multiplatform init, rerun, and Doctor in agreement", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await convertIOSFixtureToMultiplatform(root); + const configDir = await createIsolatedCLIState(); + const args = [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + ]; + + const first = await runCLI(root, args, configDir); + if (first.exitCode !== 0) throw new Error(`${first.stdout}\n${first.stderr}`); + expect(first.exitCode).toBe(0); + expect(`${first.stdout}\n${first.stderr}`).not.toContain(authFixtureKey); + + expect(await linkedProductFilters(root, "ClerkKit")).toEqual([undefined]); + expect(await linkedProductFilters(root, "ClerkKitUI")).toEqual([undefined]); + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource.match(/Clerk\.configure\(publishableKey:/g)).toHaveLength(1); + expect(appSource).toContain(".environment(Clerk.shared)"); + + const iosEntitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(iosEntitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(iosEntitlements).not.toContain("com.apple.security.app-sandbox"); + const macOSEntitlements = await Bun.file(join(root, "MyApp", "MyApp.mac.entitlements")).text(); + expect(macOSEntitlements).toContain("com.apple.security.app-sandbox"); + expect(macOSEntitlements).toContain("com.apple.security.network.client"); + expect(macOSEntitlements).not.toContain("com.apple.developer.associated-domains"); + await expectSeparatePlatformEntitlements(root); + + const remoteAfterFirst = currentNativeRemoteState(); + expect(remoteAfterFirst).toMatchObject({ + nativeAPIEnabled: true, + iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.example.MyApp" }], + mutations: { + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 0, + }, + }); + expectAutomatedDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); + + const digestAfterFirst = await treeDigest(root); + const second = await runCLI(root, args, configDir); + expect(second.exitCode).toBe(0); + expect(await treeDigest(root)).toEqual(digestAfterFirst); + expect(currentNativeRemoteState()).toEqual(remoteAfterFirst); + expectAutomatedDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); + }); + + test("stops before local or remote work when platform Swift application roots differ", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + iosSource: `import ClerkKit +import ClerkKitUI +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${authFixtureKey}") } + var body: some Scene { WindowGroup { AuthView().environment(Clerk.shared) } } +} +`, + }); + const beforeTree = await treeDigest(root); + const beforeRemote = currentNativeRemoteState(); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("different Swift application roots"); + expect(await treeDigest(root)).toEqual(beforeTree); + expect(currentNativeRemoteState()).toEqual(beforeRemote); + }); + + test("stops before local or remote work when platform Bundle IDs differ", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + sharedAppRoot: true, + iosBundleIdentifier: "com.example.MyApp.ios", + macOSBundleIdentifier: "com.example.MyApp.macos", + }); + const beforeTree = await treeDigest(root); + const beforeRemote = currentNativeRemoteState(); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "different Bundle IDs across its supported platforms", + ); + expect(await treeDigest(root)).toEqual(beforeTree); + expect(currentNativeRemoteState()).toEqual(beforeRemote); + }); + + test("revalidates every platform identity before committing an approved local plan", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await convertIOSFixtureToMultiplatform(root); + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + sharedAppRoot: true, + iosBundleIdentifier: "com.example.MyApp", + macOSBundleIdentifier: "com.example.MyApp.changed", + }); + const changedTree = await treeDigest(root); + + await expect(applyIOSPlannedLocalSetup(setup, authFixtureKey)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + }); + expect(await treeDigest(root)).toEqual(changedTree); + }); + + test("repairs only missing macOS pieces in a partial multiplatform setup", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-partial-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: "core-only", + includeKey: false, + }); + await convertIOSFixtureToSynchronizedRoot(root); + await convertIOSFixtureToMultiplatform(root); + + const appSource = `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${authFixtureKey}") + } + + var body: some Scene { + WindowGroup { Text("Custom auth").environment(Clerk.shared) } + } +} +`; + await Bun.write(join(root, "MyApp", "MyAppApp.swift"), appSource); + const iosEntitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + iosEntitlementsPath, + (await Bun.file(iosEntitlementsPath).text()).replace( + "webcredentials:clerk.example.test", + "webcredentials:ios-apply.clerk.example", + ), + ); + const iosEntitlementsBefore = await Bun.file(iosEntitlementsPath).text(); + + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectPath).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "ios"; + await Bun.write(projectPath, buildPbxProject(project)); + + const configDir = await createIsolatedCLIState(); + const args = [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + ]; + const first = await runCLI(root, args, configDir); + if (first.exitCode !== 0) throw new Error(`${first.stdout}\n${first.stderr}`); + + expect(first.exitCode).toBe(0); + expect(await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text()).toBe(appSource); + expect(await Bun.file(iosEntitlementsPath).text()).toBe(iosEntitlementsBefore); + expect(await linkedProductFilters(root, "ClerkKit")).toEqual(["ios", "macos"]); + const macOSEntitlements = await Bun.file(join(root, "MyApp", "MyApp.mac.entitlements")).text(); + expect(macOSEntitlements).toContain("com.apple.security.app-sandbox"); + expect(macOSEntitlements).toContain("com.apple.security.network.client"); + expect(macOSEntitlements).not.toContain("com.apple.developer.associated-domains"); + await expectSeparatePlatformEntitlements(root); + + const remoteAfterFirst = currentNativeRemoteState(); + expect(remoteAfterFirst).toMatchObject({ + nativeAPIEnabled: true, + iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.example.MyApp" }], + mutations: { + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 0, + }, + }); + expectAutomatedDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); + + const digestAfterFirst = await treeDigest(root); + const second = await runCLI(root, args, configDir); + expect(second.exitCode).toBe(0); + expect(await treeDigest(root)).toEqual(digestAfterFirst); + expect(currentNativeRemoteState()).toEqual(remoteAfterFirst); + expectAutomatedDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); + }); + + test("makes no local or remote plan when a configuration platform is unresolved", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-native-unresolved-platform-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + releasePlatform: "unresolved", + complete: true, + }); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_TARGET_UNRESOLVED, + message: expect.stringContaining("does not have one proven native platform"), + }); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks a visionOS-bearing target before local or remote mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-native-visionos-platform-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await convertIOSFixtureToMultiplatform(root); + await addVisionOSDestinationsToFixture(root); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_TARGET_UNRESOLVED, + message: expect.stringContaining("also ships visionOS"), + }); + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks a Catalyst-enabled target before local or remote mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-native-catalyst-platform-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await enableMacCatalystInFixture(root); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_TARGET_UNRESOLVED, + message: expect.stringContaining("also ships Mac Catalyst"), + }); + expect(await treeDigest(root)).toEqual(before); + }); + test("uses exhaustive project discovery before implicitly selecting a target", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-exhaustive-apply-selection-")); temporaryDirectories.push(root); @@ -51,7 +630,7 @@ describe("clerk init iOS SDK apply", () => { agent: true, allowDirty: false, }), - ).rejects.toThrow("More than one iOS application target is eligible"); + ).rejects.toThrow("More than one native Apple application target is eligible"); expect(await treeDigest(root)).toEqual(before); }); @@ -130,6 +709,88 @@ describe("clerk init iOS SDK apply", () => { expect(await treeDigest(root)).toEqual(before); }); + test("plans a pure macOS app without an Associated Domain action", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-local-setup-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + complete: true, + includeKey: false, + localSecrets: true, + }); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: false, + prebuiltAuthUI: false, + signInWithApple: false, + }); + + expect(setup).toMatchObject({ + platform: "macos", + associatedDomainPlan: undefined, + macOSNetworkCapabilityPlan: { status: "satisfied" }, + nativeReadiness: { + target: { status: "selected", platform: "macos" }, + associatedDomain: { status: "not-applicable", files: [], blockers: [] }, + }, + }); + expect(await treeDigest(root)).toEqual(before); + }); + + test("composes macOS network and Apple entitlements in the aggregate transaction", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-capability-apply-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + complete: true, + includeKey: false, + localSecrets: true, + macOSAppleEntitlement: false, + }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + entitlementsPath, + (await Bun.file(entitlementsPath).text()).replace( + /\s*com\.apple\.security\.network\.client<\/key>\s*/, + "", + ), + ); + + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: true, + }); + expect(setup.macOSNetworkCapabilityPlan?.status).toBe("ready"); + expect(setup.appleEntitlementPlan?.status).toBe("ready"); + + await applyIOSPlannedLocalSetup(setup); + const source = await Bun.file(entitlementsPath).text(); + expect(source).toContain("com.apple.security.network.client"); + expect(source).toContain("com.apple.developer.applesignin"); + + const firstDigest = await treeDigest(root); + const rerun = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: true, + }); + expect(rerun.macOSNetworkCapabilityPlan?.status).toBe("satisfied"); + expect(rerun.appleEntitlementPlan?.status).toBe("satisfied"); + await applyIOSPlannedLocalSetup(rerun); + expect(await treeDigest(root)).toEqual(firstDigest); + }); + test("applies the explicit prebuilt AuthView opt-in in the aggregate Swift transaction", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-apply-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index e07dce2c5..9d5f41e4c 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -18,7 +18,7 @@ import { type IOSSDKInstallPlan, type PreparedIOSSDKInstallMutation, } from "./install-sdk.ts"; -import { buildIOSSetupPlan } from "./plan.ts"; +import { buildIOSSetupPlan, selectedTargetPlatformBlockerDescription } from "./plan.ts"; import { prepareIOSDirectConfigMutation, validatePreparedIOSDirectConfig, @@ -44,6 +44,13 @@ import { type IOSAppleEntitlementPlan, type PreparedIOSAppleEntitlementMutation, } from "./apple-entitlement.ts"; +import { + planMacOSNetworkCapability, + prepareMacOSNetworkCapabilityMutation, + validatePreparedMacOSNetworkCapability, + type MacOSNetworkCapabilityPlan, + type PreparedMacOSNetworkCapabilityMutation, +} from "./macos-network.ts"; import { planIOSPrebuiltAuth, prepareIOSPrebuiltAuthMutation, @@ -57,6 +64,11 @@ import { planIOSPrebuiltAuthRuntimeBlockers, type IOSLocalSetupProposal, } from "./local-plan.ts"; +import { + iosPlatformViewsIdentityMatches, + iosPlatformViewsSnapshotsEqual, + reinspectIOSPlatformViews, +} from "./platform-views.ts"; function iosSetupError(message: string, code: ErrorCode = ERROR_CODE.IOS_SETUP_BLOCKED): CliError { return new CliError(message, { code }); @@ -82,6 +94,7 @@ export type IOSLocalSetupResult = Pick< | "sdkInstallPlan" | "directConfigPlan" | "associatedDomainPlan" + | "macOSNetworkCapabilityPlan" | "appleEntitlementPlan" | "prebuiltAuthPlan" | "prebuiltAuthAppleEntitlementPlan" @@ -90,6 +103,9 @@ export type IOSLocalSetupResult = Pick< | "nativeAppleRequested" > & { targetName: string; + platform: NonNullable; + supportedPlatforms: NonNullable; + platformViews: NonNullable; /** Authentication must return an exact app ID and development key before commit. */ requiresLinkedApp: boolean; /** The approved local transaction consumes the linked development publishable key. */ @@ -208,6 +224,7 @@ async function validatePrebuiltAuthRuntimePostcondition( const target = setup.nativeReadiness.target; const inspection = await inspectIOSProject(setup.nativeReadiness.root, { target: target.targetId, + platform: setup.platform, exhaustiveContainerDiscovery: true, }); if ( @@ -224,6 +241,14 @@ async function validatePrebuiltAuthRuntimePostcondition( return configureStep?.status === "satisfied" && environmentStep?.status === "satisfied"; } +async function validatePlatformViewsPostcondition(setup: IOSLocalSetupResult): Promise { + const current = await reinspectIOSPlatformViews(setup.platformViews); + return ( + current.status === "ready" && + iosPlatformViewsIdentityMatches(setup.platformViews, current.snapshot) + ); +} + /** * Inspects, previews, and authorizes the local iOS setup without writing it. * The returned redacted plans are prepared again and committed only after an @@ -241,7 +266,7 @@ export async function applyIOSLocalSetup( const context = createIOSLocalSetupContext(inspection); if (hasIncompleteIOSContainerDiscovery(inspection)) { throw iosSetupError( - "Xcode project discovery was incomplete, so Clerk cannot safely select an iOS application target. Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", + "Xcode project discovery was incomplete, so Clerk cannot safely select a native Apple application target. Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } @@ -255,18 +280,18 @@ export async function applyIOSLocalSetup( ) .join(", "); throwUsageError( - `More than one iOS application target is eligible: ${candidates}. Rerun with --target ; if IDs collide across copied projects, run the command from the intended project's directory.`, + `More than one native Apple application target is eligible: ${candidates}. Rerun with --target ; if IDs collide across copied projects, run the command from the intended project's directory.`, ); } if (selection.state === "not-found") { throwUsageError( - `The iOS target "${selection.requested}" was not found. Available targets: ${ + `The native Apple target "${selection.requested}" was not found. Available targets: ${ selection.candidates.join(", ") || "none" }.`, ); } throw iosSetupError( - "No usable iOS application target was found.", + "No usable iOS or macOS application target was found.", ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } @@ -274,23 +299,20 @@ export async function applyIOSLocalSetup( const selectedTarget = context.selectedTarget; if (!selectedTarget) { throw iosSetupError( - "The selected iOS target could not be resolved safely.", + "The selected native Apple target could not be resolved safely.", ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } - const productDecision = context.productDecision; - if (!productDecision) { + if (!selectedTarget.platformEvidenceComplete) { throw iosSetupError( - "The selected iOS target could not be planned safely.", - ERROR_CODE.IOS_TARGET_UNRESOLVED, - ); - } - if (productDecision === "unknown") { - throw iosSetupError( - "The selected target's Swift source membership could not be inspected completely, so Clerk cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the Xcode source-membership diagnostics, then rerun clerk init.", + `${selectedTargetPlatformBlockerDescription( + inspection, + selectedTarget, + )} No new Clerk setup changes were applied, and no remote state was changed.`, ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } + const platformLabel = selectedTarget.platform === "macos" ? "macOS" : "iOS"; const proposal = await buildIOSLocalSetupProposal(context, { root: options.root, @@ -313,6 +335,9 @@ export async function applyIOSLocalSetup( : {}), }); const { + productDecision, + platformViews, + platformCompatibilityBlockers, inspectedPrebuiltAuthPlan, prebuiltAuthPlan, prebuiltAuthRequested, @@ -322,6 +347,7 @@ export async function applyIOSLocalSetup( directConfigPlan, plannedAssociatedDomain, associatedDomainPlan, + macOSNetworkCapabilityPlan, appleEntitlementPlan, prebuiltAuthAppleEntitlementPlan, nativeAppleRequested, @@ -330,9 +356,35 @@ export async function applyIOSLocalSetup( hasSupportedCustomConfigure, prebuiltRuntimeBlockers, } = proposal; - if (!installPlan || !plannedAssociatedDomain || !inspectedPrebuiltAuthPlan) { + if (!platformViews) { throw iosSetupError( - "The selected iOS target did not produce one complete local setup proposal.", + `The selected target could not be configured safely across every supported Apple platform. No local or remote changes were made${ + platformCompatibilityBlockers.length > 0 + ? `:\n${platformCompatibilityBlockers.map((message) => ` • ${message}`).join("\n")}` + : "." + }`, + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + if (!productDecision) { + throw iosSetupError( + "The selected native Apple target could not be planned safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + if (productDecision === "unknown") { + throw iosSetupError( + "The selected target's Swift source membership could not be inspected completely, so Clerk cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the Xcode source-membership diagnostics, then rerun clerk init.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + if ( + !installPlan || + !inspectedPrebuiltAuthPlan || + (selectedTarget.platform === "ios" && !plannedAssociatedDomain) + ) { + throw iosSetupError( + `The selected ${platformLabel} target did not produce one complete local setup proposal.`, ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -349,16 +401,23 @@ export async function applyIOSLocalSetup( directConfigPlan?.sourcePath === prebuiltAuthPlan.sourcePath ) { throw iosSetupError( - "The approved iOS setup resolved the Clerk initializer and prebuilt AuthView scaffold to the same Swift source unexpectedly. No local files were changed; review the app root and rerun clerk init.", + `The approved ${platformLabel} setup resolved the Clerk initializer and prebuilt AuthView scaffold to the same Swift source unexpectedly. No local files were changed; review the app root and rerun clerk init.`, ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } + if (macOSNetworkCapabilityPlan?.status === "blocked") { + throw iosSetupError( + `Outgoing network access could not be configured safely for the selected macOS target. No local files were changed:\n${blockerList( + macOSNetworkCapabilityPlan.blockers, + )}`, + ); + } if ( nativeReadiness.target.status !== "selected" || nativeReadiness.target.bundleIdentifier.status !== "resolved" ) { throw iosSetupError( - "The selected iOS target does not have one proven Bundle ID across all build configurations. No local files were changed; resolve PRODUCT_BUNDLE_IDENTIFIER, then rerun clerk init.", + `The selected ${platformLabel} target does not have one proven Bundle ID across all build configurations. No local files were changed; resolve PRODUCT_BUNDLE_IDENTIFIER, then rerun clerk init.`, ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } @@ -421,7 +480,7 @@ export async function applyIOSLocalSetup( ); } else if (installPlan.status === "blocked") { throw iosSetupError( - `The Clerk iOS SDK could not be installed automatically:\n${blockerList( + `The Clerk ${selectedTarget.platform === "macos" ? "Swift" : "iOS"} SDK could not be installed automatically:\n${blockerList( installPlan.blockers, )}`, ); @@ -459,6 +518,20 @@ export async function applyIOSLocalSetup( }); } } + if (macOSNetworkCapabilityPlan?.status === "ready") { + if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of macOSNetworkCapabilityPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } if (appleEntitlementPlan?.status === "ready") { if (appleEntitlementPlan.missingEntitlementsSettings) { plannedPaths.push({ @@ -516,12 +589,18 @@ export async function applyIOSLocalSetup( directConfigNeedsWrite(directConfigPlan) || prebuiltAuthPlan?.status === "ready" || associatedDomainNeedsWrite(associatedDomainPlan) || + macOSNetworkCapabilityPlan?.status === "ready" || appleEntitlementPlan?.status === "ready" || prebuiltAuthAppleEntitlementPlan?.status === "ready"; if (hasLocalWrites) { - log.info("\nclerk init will make the following local iOS changes:\n"); - } else if (directConfigPlan || appleEntitlementPlan || prebuiltAuthPlan) { - log.info("\nclerk init will perform the following read-only iOS verification:\n"); + 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")} ${selection.projectPath}/project.pbxproj`); @@ -565,16 +644,35 @@ export async function applyIOSLocalSetup( ); } } + if (macOSNetworkCapabilityPlan?.status === "ready") { + if ( + macOSNetworkCapabilityPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + 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) + 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 + !associatedDomainPlan?.missingEntitlementsSettings && + !macOSNetworkCapabilityPlan?.missingEntitlementsSettings ) { log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); } @@ -606,6 +704,14 @@ export async function applyIOSLocalSetup( } for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); } + if (macOSNetworkCapabilityPlan?.status === "ready") { + if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + for (const file of macOSNetworkCapabilityPlan.files) { + alreadyPreviewedPaths.add(file.path); + } + } if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { const projectFile = `${selection.projectPath}/project.pbxproj`; if (!alreadyPreviewedPaths.has(projectFile)) { @@ -634,8 +740,8 @@ export async function applyIOSLocalSetup( log.info( dim( nativeAppleRequested - ? "\n After authentication, clerk init will inspect Native API, iOS registration, and the native Apple connection before separately previewing additive remote changes." - : "\n After authentication, clerk init will inspect Native API and iOS registration state and separately preview any additive remote changes.", + ? `\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(); @@ -647,7 +753,7 @@ export async function applyIOSLocalSetup( } if (hasLocalWrites && !options.yes) { const proceed = await confirm({ - message: "Apply these local iOS changes?", + message: `Apply these local ${platformLabel} changes?`, default: false, }); if (!proceed) throwUserAbort(); @@ -656,6 +762,9 @@ export async function applyIOSLocalSetup( return { ...proposal, targetName: selection.targetName, + platform: selectedTarget.platform, + supportedPlatforms: [...selectedTarget.supportedPlatforms], + platformViews, requiresLinkedApp: true, requiresDevelopmentKey: directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, @@ -709,7 +818,7 @@ async function prepareSDKForCommit( } if (prepared.status === "blocked") { throw iosSetupError( - `The Clerk iOS SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers( + `The Clerk ${prepared.plan.platform === "macos" ? "Swift" : "iOS"} SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers( prepared, )}`, ); @@ -774,7 +883,7 @@ async function prepareAppleEntitlementForCommit( }); if (prepared.status === "stale") { throw iosSetupError( - "An iOS entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", + "A native Apple entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_STALE, ); } @@ -788,6 +897,40 @@ async function prepareAppleEntitlementForCommit( return prepared; } +async function prepareMacOSNetworkForCommit( + plan: MacOSNetworkCapabilityPlan | undefined, + baseMutations: readonly IOSFileMutation[], +): Promise { + if (!plan) return undefined; + const prepared = await prepareMacOSNetworkCapabilityMutation(plan, { baseMutations }); + if (prepared.status === "stale") { + throw iosSetupError( + "The macOS sandbox or entitlements configuration changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `Outgoing network access could no longer be prepared safely. No local setup changes were written:\n${blockerList( + prepared.plan.blockers, + )}`, + ); + } + return prepared; +} + +function composeMacOSNetworkMutations( + baseMutations: readonly IOSFileMutation[], + prepared: PreparedMacOSNetworkCapabilityMutation | undefined, +): IOSFileMutation[] { + if (prepared?.status !== "ready") return [...baseMutations]; + const consumed = new Set(prepared.consumedBaseMutationPaths); + return [ + ...baseMutations.filter((mutation) => !consumed.has(resolve(mutation.path))), + ...prepared.mutations, + ]; +} + function composeAppleMutations( baseMutations: readonly IOSFileMutation[], prepared: PreparedIOSAppleEntitlementMutation | undefined, @@ -804,7 +947,7 @@ function assertUniqueMutationPaths(mutations: readonly IOSFileMutation[]): void const paths = mutations.map((mutation) => resolve(mutation.path)); if (new Set(paths).size !== paths.length) { throw iosSetupError( - "The approved iOS setup produced overlapping file mutations. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup produced overlapping file mutations. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -815,6 +958,7 @@ async function validateSatisfiedAssociatedDomain(plan: IOSAssociatedDomainPlan): root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, }); return ( current.status === "satisfied" && @@ -827,6 +971,17 @@ async function validateSatisfiedAppleEntitlement(plan: IOSAppleEntitlementPlan): root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, + supportedPlatforms: plan.supportedPlatforms, + }); + return current.status === "satisfied"; +} + +async function validateSatisfiedMacOSNetwork(plan: MacOSNetworkCapabilityPlan): Promise { + const current = await planMacOSNetworkCapability({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, }); return current.status === "satisfied"; } @@ -836,6 +991,7 @@ async function validateSatisfiedPrebuiltAuth(plan: IOSPrebuiltAuthPlan): Promise root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowDirty: true, }); return current.status === "satisfied" && current.sourcePath === plan.sourcePath; @@ -850,7 +1006,7 @@ function requireDevelopmentKey( ); if (planNeedsKey !== setup.requiresDevelopmentKey) { throw iosSetupError( - "The approved iOS setup plan is internally inconsistent. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup plan is internally inconsistent. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -867,7 +1023,7 @@ function requireDevelopmentKey( function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { if (setup.prebuiltAuthRequested && !setup.prebuiltAuthPlan) { throw iosSetupError( - "The approved iOS setup selected prebuilt authentication without a validated source plan. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup selected prebuilt authentication without a validated source plan. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -875,13 +1031,13 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { setup.prebuiltAuthRequested || setup.prebuiltAuthPlan?.status === "satisfied"; if (setup.prebuiltAuthActive !== expectedPrebuiltAuthActive) { throw iosSetupError( - "The approved iOS setup contains inconsistent prebuilt authentication state. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup contains inconsistent prebuilt authentication state. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } if (setup.prebuiltAuthAppleEntitlementPlan && !setup.prebuiltAuthActive) { throw iosSetupError( - "The approved iOS setup contains an unselected AuthView capability plan. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup contains an unselected AuthView capability plan. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -891,7 +1047,7 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { setup.directConfigPlan.sourcePath === setup.prebuiltAuthPlan.sourcePath ) { throw iosSetupError( - "The approved iOS setup contains overlapping Swift source mutations. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup contains overlapping Swift source mutations. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -902,13 +1058,45 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { ].filter((plan) => plan != null); if (setup.prebuiltAuthPlan) plans.push(setup.prebuiltAuthPlan); if (setup.associatedDomainPlan) plans.push(setup.associatedDomainPlan); + if (setup.macOSNetworkCapabilityPlan) plans.push(setup.macOSNetworkCapabilityPlan); if (setup.appleEntitlementPlan) plans.push(setup.appleEntitlementPlan); if (setup.prebuiltAuthAppleEntitlementPlan) { plans.push(setup.prebuiltAuthAppleEntitlementPlan); } if (setup.nativeReadiness.target.status !== "selected") { throw iosSetupError( - "The approved iOS setup no longer identifies one selected native target. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup no longer identifies one selected target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if ( + setup.platformViews.root !== setup.nativeReadiness.root || + setup.platformViews.projectPath !== setup.nativeReadiness.target.projectPath || + setup.platformViews.targetId !== setup.nativeReadiness.target.targetId || + setup.platformViews.primaryPlatform !== setup.platform || + JSON.stringify(setup.platformViews.supportedPlatforms) !== + JSON.stringify(setup.supportedPlatforms) + ) { + throw iosSetupError( + "The approved native Apple setup contains inconsistent multiplatform target evidence. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (setup.nativeReadiness.target.platform !== setup.platform) { + throw iosSetupError( + "The approved native Apple setup no longer identifies one consistent platform. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (!setup.supportedPlatforms.includes(setup.platform)) { + throw iosSetupError( + "The approved native Apple setup contains inconsistent supported-platform state. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (setup.supportedPlatforms.includes("macos") !== (setup.macOSNetworkCapabilityPlan != null)) { + throw iosSetupError( + "The approved native Apple setup contains inconsistent macOS network-capability state. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -928,7 +1116,7 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { ) ) { throw iosSetupError( - "The approved iOS setup no longer identifies one consistent Xcode target. No local setup changes were written; rerun clerk init.", + "The approved native Apple setup no longer identifies one consistent Xcode target. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } @@ -946,22 +1134,35 @@ export async function applyIOSPlannedLocalSetup( options: ApplyIOSPlannedLocalSetupOptions = {}, ): Promise { assertCoherentLocalSetup(setup); + const currentPlatformViews = await reinspectIOSPlatformViews(setup.platformViews); + if ( + currentPlatformViews.status !== "ready" || + !iosPlatformViewsSnapshotsEqual(setup.platformViews, currentPlatformViews.snapshot) + ) { + throw iosSetupError( + "The selected target's multiplatform Swift setup or native identity changed after the approved preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + const platformLabel = setup.platform === "macos" ? "macOS" : "iOS"; if (setup.prebuiltAuthActive) { if (setup.nativeReadiness.target.status !== "selected") { throw iosSetupError( - "The approved prebuilt AuthView setup no longer identifies one selected iOS target. No local setup changes were written; rerun clerk init.", + "The approved prebuilt AuthView setup no longer identifies one selected native Apple target. No local setup changes were written; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } const inspection = await inspectIOSProject(setup.nativeReadiness.root, { target: setup.nativeReadiness.target.targetId, + platform: setup.platform, exhaustiveContainerDiscovery: true, }); if ( hasIncompleteIOSContainerDiscovery(inspection) || inspection.selection.state !== "selected" || inspection.selection.targetId !== setup.nativeReadiness.target.targetId || - inspection.selection.projectPath !== setup.nativeReadiness.target.projectPath + inspection.selection.projectPath !== setup.nativeReadiness.target.projectPath || + inspection.selection.platform !== setup.platform ) { throw iosSetupError( "The approved prebuilt AuthView setup no longer identifies the same exhaustively discovered Xcode target. No local setup changes were written; rerun clerk init.", @@ -1037,11 +1238,21 @@ export async function applyIOSPlannedLocalSetup( validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan), ); } + const preparedMacOSNetwork = await prepareMacOSNetworkForCommit( + setup.macOSNetworkCapabilityPlan, + baseMutations, + ); + const networkMutations = composeMacOSNetworkMutations(baseMutations, preparedMacOSNetwork); + if (preparedMacOSNetwork?.status === "ready") { + postconditions.push(async () => validatePreparedMacOSNetworkCapability(preparedMacOSNetwork)); + } else if (preparedMacOSNetwork?.status === "satisfied") { + postconditions.push(async () => validateSatisfiedMacOSNetwork(preparedMacOSNetwork.plan)); + } const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( setup.appleEntitlementPlan, - baseMutations, + networkMutations, ); - const mutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + const mutations = composeAppleMutations(networkMutations, preparedAppleEntitlement); if (preparedAppleEntitlement?.status === "ready") { postconditions.push(async () => validatePreparedIOSAppleEntitlement(preparedAppleEntitlement), @@ -1073,21 +1284,22 @@ export async function applyIOSPlannedLocalSetup( if (setup.prebuiltAuthActive) { postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup)); } + postconditions.push(async () => validatePlatformViewsPostcondition(setup)); assertUniqueMutationPaths(mutations); if (mutations.length > 0) { - const result = await withSpinner("Applying the local iOS setup...", async () => + const result = await withSpinner(`Applying the local ${platformLabel} setup...`, async () => applyIOSFileTransaction(mutations, postconditions), ); if (result.status === "stale") { throw iosSetupError( - "An iOS setup file changed while the approved changes were being committed. Any partial write was restored; rerun clerk init.", + "A native Apple setup file changed while the approved changes were being committed. Any partial write was restored; rerun clerk init.", ERROR_CODE.IOS_SETUP_STALE, ); } if (result.status === "rolled-back") { throw iosSetupError( - "The local iOS setup failed post-write validation and was restored byte-for-byte.", + "The local native Apple setup failed post-write validation and was restored byte-for-byte.", ERROR_CODE.IOS_LOCAL_APPLY_FAILED, ); } @@ -1107,6 +1319,9 @@ export async function applyIOSPlannedLocalSetup( if (preparedAssociatedDomain?.status === "ready") { log.success("Clerk Associated Domain added to the selected target entitlements"); } + if (preparedMacOSNetwork?.status === "ready") { + log.success("Outgoing network access enabled for the selected macOS target"); + } if (preparedAppleEntitlement?.status === "ready") { log.success("Sign in with Apple entitlement added to the selected target"); } @@ -1131,11 +1346,16 @@ export async function applyIOSPlannedLocalSetup( ? [prebuiltAuthFileMutation(preparedPrebuiltAuth)] : []), ]; + const preparedMacOSNetwork = await prepareMacOSNetworkForCommit( + setup.macOSNetworkCapabilityPlan, + baseMutations, + ); + const networkMutations = composeMacOSNetworkMutations(baseMutations, preparedMacOSNetwork); const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( setup.appleEntitlementPlan, - baseMutations, + networkMutations, ); - const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + const localMutations = composeAppleMutations(networkMutations, preparedAppleEntitlement); assertUniqueMutationPaths(localMutations); // SDK-only and custom-runtime routes apply their local candidates together @@ -1148,6 +1368,11 @@ export async function applyIOSPlannedLocalSetup( : preparedAssociatedDomain?.status === "satisfied" ? [async () => validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan)] : []), + ...(preparedMacOSNetwork?.status === "ready" + ? [async () => validatePreparedMacOSNetworkCapability(preparedMacOSNetwork)] + : preparedMacOSNetwork?.status === "satisfied" + ? [async () => validateSatisfiedMacOSNetwork(preparedMacOSNetwork.plan)] + : []), ...(preparedAppleEntitlement?.status === "ready" ? [async () => validatePreparedIOSAppleEntitlement(preparedAppleEntitlement)] : preparedAppleEntitlement?.status === "satisfied" @@ -1161,6 +1386,7 @@ export async function applyIOSPlannedLocalSetup( ...(setup.prebuiltAuthActive ? [async () => validatePrebuiltAuthRuntimePostcondition(setup)] : []), + async () => validatePlatformViewsPostcondition(setup), ]; if (options.beforePostWriteValidation) { postconditions.push(async () => { @@ -1168,7 +1394,7 @@ export async function applyIOSPlannedLocalSetup( return true; }); } - const result = await withSpinner("Applying the local iOS setup...", async () => + const result = await withSpinner(`Applying the local ${platformLabel} setup...`, async () => applyIOSFileTransaction(localMutations, postconditions), ); if (result.status === "stale") { @@ -1179,7 +1405,7 @@ export async function applyIOSPlannedLocalSetup( } if (result.status === "rolled-back") { throw iosSetupError( - "The local iOS setup changed during post-write validation. The Clerk iOS SDK change was restored byte-for-byte; rerun clerk init.", + "The local native Apple setup changed during post-write validation. The Clerk SDK change was restored byte-for-byte; rerun clerk init.", ERROR_CODE.IOS_LOCAL_APPLY_FAILED, ); } @@ -1189,6 +1415,9 @@ export async function applyIOSPlannedLocalSetup( if (preparedAssociatedDomain?.status === "ready") { log.success("Clerk Associated Domain added to the selected target entitlements"); } + if (preparedMacOSNetwork?.status === "ready") { + log.success("Outgoing network access enabled for the selected macOS target"); + } if (preparedAppleEntitlement?.status === "ready") { log.success("Sign in with Apple entitlement added to the selected target"); } 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 8e95f08cd..a3a17c477 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -26,7 +26,12 @@ import { import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; -import type { IOSAppTarget, IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; +import type { + IOSAppTarget, + IOSDiagnostic, + IOSNativePlatform, + IOSProjectInspectionResult, +} from "./types.ts"; const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains"; const MAX_ENTITLEMENTS_BYTES = 1_000_000; @@ -34,6 +39,7 @@ const MAX_ENTITLEMENTS_BYTES = 1_000_000; export type IOSAssociatedDomainBlockerCode = | "invalid-selection" | "generated-project" + | "unresolved-platform" | "runtime-key-unproven" | "missing-entitlements" | "mixed-entitlements" @@ -63,6 +69,7 @@ export interface IOSAssociatedDomainPlan { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; targetName?: string; /** Public Frontend API hostname only. A publishable key is never retained. */ expectedDomain?: string; @@ -80,10 +87,14 @@ 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. */ + platform?: IOSNativePlatform; /** A separately proven direct Swift configuration will supply the runtime key after auth. */ deferToPublishableKey?: boolean; /** Allows the strict synchronized-root planner to create and attach a new file. */ allowMissingEntitlementsCreation?: boolean; + /** Capability planners may allow one selected target to share a file across its platforms. */ + allowSelectedTargetPlatformSharing?: boolean; } export type PreparedIOSAssociatedDomainMutation = @@ -140,6 +151,7 @@ function blockedPlan( root: resolve(options.root), projectPath: options.projectPath, targetId: options.targetId, + platform: options.platform ?? "ios", ...(targetName ? { targetName } : {}), requiresPublishableKey: options.deferToPublishableKey === true, files: [], @@ -393,6 +405,8 @@ async function ownershipIsExclusive( projectPath: string, selectedTargetId: string, selectedFiles: readonly EntitlementsFile[], + selectedPlatform: IOSNativePlatform, + allowSelectedTargetPlatformSharing = false, ): Promise { try { const selectedCanonical = new Set(); @@ -433,12 +447,11 @@ async function ownershipIsExclusive( ); for (const targetId of targetIds) { - if (absoluteProject === selectedProject && targetId === selectedTargetId) continue; const targetObject = objects[targetId]; if (!targetObject) return false; if (targetObject.isa !== "PBXNativeTarget") continue; - const diagnostics: IOSDiagnostic[] = []; - const configurations = await inspectTargetBuildConfigurations({ + const primaryDiagnostics: IOSDiagnostic[] = []; + const primaryConfigurations = await inspectTargetBuildConfigurations({ root, projectPath: absoluteProject, groupRootDirectory, @@ -447,28 +460,87 @@ async function ownershipIsExclusive( targetObject, objects, parents, - diagnostics, + diagnostics: primaryDiagnostics, }); if ( - configurations.length === 0 || - diagnostics.some((diagnostic) => diagnostic.severity === "error") + primaryConfigurations.length === 0 || + primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || + primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") ) { return false; } - for (const configuration of 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; + + 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. } - } catch { - // A missing sibling entitlements path cannot currently alias an existing selected file. } } } @@ -534,9 +606,11 @@ export async function planIOSAssociatedDomain( options: IOSAssociatedDomainPlanOptions, ): Promise { const root = resolve(options.root); + const platform = options.platform ?? "ios"; const inspection = await inspectIOSProject(root, { target: options.targetId, exhaustiveContainerDiscovery: true, + platform, }); const target = selectedTarget(inspection, options.projectPath, options.targetId); if (!target) { @@ -544,6 +618,18 @@ export async function planIOSAssociatedDomain( blocker("invalid-selection", "The selected iOS target could not be resolved exactly."), ]); } + 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))); @@ -616,6 +702,7 @@ export async function planIOSAssociatedDomain( root, projectPath: options.projectPath, targetId: options.targetId, + platform, }); if (settingsPlan.status === "ready" && settingsPlan.entitlementsPath) { return { @@ -625,6 +712,7 @@ export async function planIOSAssociatedDomain( root, projectPath: options.projectPath, targetId: options.targetId, + platform, targetName: target.name, ...(expectedDomain ? { expectedDomain } : {}), requiresPublishableKey: expectedDomain == null, @@ -634,7 +722,9 @@ export async function planIOSAssociatedDomain( expectedDomain ? `Create ${settingsPlan.entitlementsPath} with ${expectedDomain}.` : `Create ${settingsPlan.entitlementsPath} with the linked development instance's exact webcredentials host (resolved after authentication).`, - `Attach ${settingsPlan.entitlementsPath} only to iPhone and iPad SDK builds for every selected-target configuration.`, + 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: [], }; @@ -706,7 +796,16 @@ export async function planIOSAssociatedDomain( const files = [...filesByPath.values()].sort((a, b) => a.relativePath.localeCompare(b.relativePath), ); - if (!(await ownershipIsExclusive(root, options.projectPath, options.targetId, files))) { + if ( + !(await ownershipIsExclusive( + root, + options.projectPath, + options.targetId, + files, + platform, + options.allowSelectedTargetPlatformSharing, + )) + ) { return blockedPlan( options, [ @@ -729,6 +828,7 @@ export async function planIOSAssociatedDomain( root, projectPath: options.projectPath, targetId: options.targetId, + platform, targetName: target.name, ...(expectedDomain ? { expectedDomain } : {}), requiresPublishableKey: expectedDomain == null, @@ -922,6 +1022,7 @@ export async function prepareIOSAssociatedDomainMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, deferToPublishableKey: plan.requiresPublishableKey, allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, }); @@ -1038,10 +1139,11 @@ export async function validatePreparedIOSAssociatedDomain( const inspection = await inspectIOSProject(prepared.plan.root, { target: prepared.plan.targetId, exhaustiveContainerDiscovery: true, + platform: prepared.plan.platform, }); if (hasIncompleteIOSContainerDiscovery(inspection)) return false; const target = selectedTarget(inspection, prepared.plan.projectPath, prepared.plan.targetId); - if (!target) return false; + if (!target?.platformEvidenceComplete) return false; if ( inspection.generatedProject != null || (await generatedProjectKind( @@ -1079,6 +1181,7 @@ export async function validatePreparedIOSAssociatedDomain( prepared.plan.projectPath, prepared.plan.targetId, [...new Map(files.map((file) => [file.absolutePath, file])).values()], + prepared.plan.platform, ); } diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 13099ac0b..73d531b60 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; import { buildIOSSetupPlan } from "./plan.ts"; -import type { IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; +import type { IOSDiagnostic, IOSNativePlatform, IOSProjectInspectionResult } from "./types.ts"; const temporaryDirectories: string[] = []; @@ -17,9 +17,12 @@ interface BuildSettingsFixtureOptions { xcconfig?: string; includedXCConfig?: string; projectDirPath?: string; + projectBuildSettings?: Record; + omitSupportedPlatforms?: boolean; targetBuildSettings?: Record; projectConfigurationIds?: string[]; targetConfigurationIds?: string[]; + inspectionPlatform?: IOSNativePlatform; } async function inspectFixture(options: BuildSettingsFixtureOptions = {}) { @@ -37,7 +40,7 @@ async function inspectFixture(options: BuildSettingsFixtureOptions = {}) { "project-debug": { isa: "XCBuildConfiguration", name: "Debug", - buildSettings: { SDKROOT: "iphoneos" }, + buildSettings: { SDKROOT: "iphoneos", ...options.projectBuildSettings }, }, "target-list": { isa: "XCConfigurationList", @@ -51,7 +54,9 @@ async function inspectFixture(options: BuildSettingsFixtureOptions = {}) { PRODUCT_BUNDLE_IDENTIFIER: "com.example.Example", DEVELOPMENT_TEAM: "ABCDE12345", IPHONEOS_DEPLOYMENT_TARGET: "17.0", - SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + ...(options.omitSupportedPlatforms + ? {} + : { SUPPORTED_PLATFORMS: "iphoneos iphonesimulator" }), ...options.targetBuildSettings, }, }, @@ -105,6 +110,7 @@ async function inspectFixture(options: BuildSettingsFixtureOptions = {}) { objects, parents: new Map(), diagnostics, + platform: options.inspectionPlatform, }); return { configurations, diagnostics, root }; } @@ -627,7 +633,10 @@ describe("inspectTargetBuildConfigurations", () => { }, }); - expect(configurations[0]?.isIOS).toBe(true); + expect(configurations[0]).toMatchObject({ + platform: "ios", + platformEvidenceComplete: false, + }); expect(diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -656,7 +665,7 @@ describe("inspectTargetBuildConfigurations", () => { }, }); - expect(configurations[0]?.isIOS).toBe(true); + expect(configurations[0]?.platform).toBe("ios"); }); test("still rejects targets with fully resolved non-iOS platform evidence", async () => { @@ -668,7 +677,7 @@ describe("inspectTargetBuildConfigurations", () => { }, }); - expect(configurations[0]?.isIOS).toBe(false); + expect(configurations[0]?.platform).toBeUndefined(); }); test("rejects resolved non-iOS targets despite a stale iOS deployment target", async () => { @@ -680,7 +689,7 @@ describe("inspectTargetBuildConfigurations", () => { }, }); - expect(configurations[0]?.isIOS).toBe(false); + expect(configurations[0]?.platform).toBeUndefined(); }); test("keeps targets when some non-iOS platform evidence remains unresolved", async () => { @@ -692,7 +701,230 @@ describe("inspectTargetBuildConfigurations", () => { }, }); - expect(configurations[0]?.isIOS).toBe(true); + expect(configurations[0]?.platform).toBe("ios"); + }); + + test("classifies Clerk's native macOS app settings and resolves both architectures", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "macosx", + SUPPORTED_PLATFORMS: "macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + IPHONEOS_DEPLOYMENT_TARGET: "", + "PRODUCT_BUNDLE_IDENTIFIER[arch=arm64]": "com.clerk.MacExampleApp", + "PRODUCT_BUNDLE_IDENTIFIER[arch=x86_64]": "com.clerk.MacExampleApp", + "ENABLE_APP_SANDBOX[arch=arm64]": "YES", + "ENABLE_APP_SANDBOX[arch=x86_64]": "YES", + "ENABLE_OUTGOING_NETWORK_CONNECTIONS[arch=arm64]": "YES", + "ENABLE_OUTGOING_NETWORK_CONNECTIONS[arch=x86_64]": "YES", + }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "macos", + platformEvidenceComplete: true, + model: { + bundleIdentifier: { state: "resolved", value: "com.clerk.MacExampleApp" }, + deploymentTarget: { state: "resolved", value: "14.0" }, + appSandbox: { state: "resolved", value: "YES" }, + outgoingNetworkConnections: { state: "resolved", value: "YES" }, + }, + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "macosx/arm64", + "macosx/x86_64", + ]); + expect(diagnostics).not.toContainEqual( + expect.objectContaining({ code: "xcode.conflicting-build-setting" }), + ); + }); + + test("keeps an iOS-capable multiplatform target on the iOS automation path", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "iphoneos", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + }, + }); + + expect(configurations[0]?.platform).toBe("ios"); + expect(configurations[0]?.platformEvidenceComplete).toBe(true); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphoneos/arm64", + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + }); + + test("marks modeled platform evidence incomplete when the target also declares visionOS", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { + SDKROOT: "auto", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator macosx xros xrsimulator", + MACOSX_DEPLOYMENT_TARGET: "14.0", + }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios", "macos"], + unmodeledPlatforms: ["xros", "xrsimulator"], + platformEvidenceComplete: false, + }); + expect(configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "iphoneos/arm64", + "iphonesimulator/arm64", + "iphonesimulator/x86_64", + ]); + }); + + test("marks an explicitly Catalyst-enabled target as unmodeled", async () => { + const { configurations } = await inspectFixture({ + targetBuildSettings: { SUPPORTS_MACCATALYST: "YES" }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios"], + unmodeledPlatforms: ["maccatalyst"], + platformEvidenceComplete: false, + }); + }); + + test.each([ + { name: "absent", targetBuildSettings: {} }, + { name: "disabled", targetBuildSettings: { SUPPORTS_MACCATALYST: "NO" } }, + ] as Array<{ name: string; targetBuildSettings: Record }>)( + "keeps Catalyst $name targets on the iOS automation path", + async ({ targetBuildSettings }) => { + const { configurations } = await inspectFixture({ targetBuildSettings }); + + expect(configurations[0]).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios"], + unmodeledPlatforms: [], + platformEvidenceComplete: true, + }); + }, + ); + + test("fails platform evidence closed when Catalyst support is unresolved", async () => { + const { configurations, diagnostics } = await inspectFixture({ + targetBuildSettings: { SUPPORTS_MACCATALYST: "$(UNKNOWN_CATALYST_SETTING)" }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "ios", + unmodeledPlatforms: [], + platformEvidenceComplete: false, + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("SUPPORTS_MACCATALYST"), + }), + ); + }); + + test.each([ + { name: "enabled", value: "YES", expectedPlatforms: ["maccatalyst"] }, + { + name: "unresolved", + value: "$(UNKNOWN_CATALYST_SETTING)", + expectedPlatforms: [], + }, + ])( + "blocks an SDKROOT-proven iOS target with $name Catalyst support when SUPPORTED_PLATFORMS is absent", + async ({ value, expectedPlatforms }) => { + const { configurations, diagnostics } = await inspectFixture({ + omitSupportedPlatforms: true, + targetBuildSettings: { SUPPORTS_MACCATALYST: value }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios"], + unmodeledPlatforms: expectedPlatforms, + platformEvidenceComplete: false, + }); + expect( + diagnostics.some( + (diagnostic) => + diagnostic.code === "xcode.unresolved-build-setting" && + diagnostic.message.includes("SUPPORTS_MACCATALYST"), + ), + ).toBe(value !== "YES"); + }, + ); + + test.each([ + { name: "enabled", value: "YES" }, + { name: "unresolved", value: "$(UNKNOWN_CATALYST_SETTING)" }, + ])( + "ignores an inherited $name Catalyst setting for a native macOS-only target", + async ({ value }) => { + const { configurations, diagnostics } = await inspectFixture({ + projectBuildSettings: { SUPPORTS_MACCATALYST: value }, + targetBuildSettings: { + SDKROOT: "macosx", + SUPPORTED_PLATFORMS: "macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + }, + }); + + expect(configurations[0]).toMatchObject({ + platform: "macos", + supportedPlatforms: ["macos"], + unmodeledPlatforms: [], + platformEvidenceComplete: true, + }); + expect(diagnostics).not.toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-build-setting", + message: expect.stringContaining("SUPPORTS_MACCATALYST"), + }), + ); + }, + ); + + test("inspects a proven macOS view without changing the multiplatform primary platform", async () => { + const settings = { + SDKROOT: "auto", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator macosx", + MACOSX_DEPLOYMENT_TARGET: "14.0", + }; + const primary = await inspectFixture({ targetBuildSettings: settings }); + const macOS = await inspectFixture({ + targetBuildSettings: settings, + inspectionPlatform: "macos", + }); + + expect(primary.configurations[0]).toMatchObject({ + platform: "ios", + supportedPlatforms: ["ios", "macos"], + platformEvidenceComplete: true, + }); + expect(macOS.configurations[0]).toMatchObject({ + platform: "macos", + supportedPlatforms: ["ios", "macos"], + platformEvidenceComplete: true, + model: { deploymentTarget: { state: "resolved", value: "14.0" } }, + }); + expect(macOS.configurations[0]?.entitlementContexts.map((context) => context.label)).toEqual([ + "macosx/arm64", + "macosx/x86_64", + ]); + }); + + test("marks a forced macOS view incomplete when the configuration only supports iOS", async () => { + const { configurations } = await inspectFixture({ inspectionPlatform: "macos" }); + + expect(configurations[0]).toMatchObject({ + platform: "macos", + supportedPlatforms: ["ios"], + platformEvidenceComplete: false, + }); }); test("preserves dangling target configurations as blocking placeholders", async () => { @@ -723,6 +955,9 @@ describe("inspectTargetBuildConfigurations", () => { { id: "target", name: "Example", + platform: "ios", + supportedPlatforms: ["ios"], + platformEvidenceComplete: false, projectPath: "Example.xcodeproj", configurations: configurations.map(({ model }) => model), packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, @@ -750,6 +985,7 @@ describe("inspectTargetBuildConfigurations", () => { targetId: "target", targetName: "Example", projectPath: "Example.xcodeproj", + platform: "ios", }, localPublishableKey: { state: "missing" }, generatedProject: null, diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 327272bb4..8cfb15a55 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -14,6 +14,7 @@ import { import type { IOSBuildConfiguration, IOSDiagnostic, + IOSNativePlatform, IOSSourceEvidence, IOSValueResolution, } from "./types.ts"; @@ -25,13 +26,24 @@ const INSPECTED_BUILD_SETTING_KEYS = [ "DEVELOPMENT_TEAM", "CODE_SIGN_ENTITLEMENTS", "IPHONEOS_DEPLOYMENT_TARGET", + "MACOSX_DEPLOYMENT_TARGET", + "ENABLE_APP_SANDBOX", + "ENABLE_OUTGOING_NETWORK_CONNECTIONS", "SDKROOT", "SUPPORTED_PLATFORMS", + "SUPPORTS_MACCATALYST", ] as const; +const MODELED_SUPPORTED_PLATFORM_TOKENS = new Set(["iphoneos", "iphonesimulator", "macosx"]); interface BuildContext { - label: "iphoneos/arm64" | "iphonesimulator/arm64" | "iphonesimulator/x86_64"; - sdk: "iphoneos" | "iphonesimulator"; + label: + | "iphoneos/arm64" + | "iphonesimulator/arm64" + | "iphonesimulator/x86_64" + | "macosx/arm64" + | "macosx/x86_64"; + platform: IOSNativePlatform; + sdk: "iphoneos" | "iphonesimulator" | "macosx"; arch: "arm64" | "x86_64"; } @@ -51,9 +63,21 @@ type XCConfigOperation = | { kind: "unresolved-continuation" }; const BUILD_CONTEXTS: BuildContext[] = [ - { label: "iphoneos/arm64", sdk: "iphoneos", arch: "arm64" }, - { label: "iphonesimulator/arm64", sdk: "iphonesimulator", arch: "arm64" }, - { label: "iphonesimulator/x86_64", sdk: "iphonesimulator", arch: "x86_64" }, + { label: "iphoneos/arm64", platform: "ios", sdk: "iphoneos", arch: "arm64" }, + { + label: "iphonesimulator/arm64", + platform: "ios", + sdk: "iphonesimulator", + arch: "arm64", + }, + { + label: "iphonesimulator/x86_64", + platform: "ios", + sdk: "iphonesimulator", + arch: "x86_64", + }, + { label: "macosx/arm64", platform: "macos", sdk: "macosx", arch: "arm64" }, + { label: "macosx/x86_64", platform: "macos", sdk: "macosx", arch: "x86_64" }, ]; interface XCConfigCondition { @@ -687,6 +711,15 @@ async function settingsForConfiguration( export interface InspectedTargetConfiguration { model: IOSBuildConfiguration; entitlementContexts: EntitlementBuildContext[]; + /** Modeled native platforms declared or inferred for this configuration. */ + supportedPlatforms: IOSNativePlatform[]; + /** Declared Xcode platforms that this CLI does not model for automatic setup. */ + unmodeledPlatforms: string[]; + /** Undefined when resolved platform evidence excludes iOS and macOS. */ + platform?: IOSNativePlatform; + /** True only when concrete build settings prove this configuration's platform. */ + platformEvidenceComplete: boolean; + /** Compatibility flag for existing iOS-only mutation planners. */ isIOS: boolean; } @@ -723,6 +756,7 @@ function resolveSettingAcrossContexts( targetName: string, configurationName: string, diagnostics: IOSDiagnostic[], + reportConflict = true, ): IOSValueResolution { const variants = contexts.map(({ context, evaluation, builtins }) => ({ context, @@ -732,16 +766,18 @@ function resolveSettingAcrossContexts( if (signatures.size <= 1) return variants[0]?.resolution ?? { state: "missing", evidence: [evidence] }; - addDiagnosticOnce(diagnostics, { - code: "xcode.conflicting-build-setting", - severity: "warning", - message: `${targetName} ${configurationName} has different ${key} values by SDK and architecture: ${variants - .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) - .join(", ")}`, - remedy: - "Make device and simulator architecture values consistent or select the intended SDK and architecture explicitly.", - evidence: variants.flatMap(({ resolution }) => resolution.evidence), - }); + if (reportConflict) { + addDiagnosticOnce(diagnostics, { + code: "xcode.conflicting-build-setting", + severity: "warning", + message: `${targetName} ${configurationName} has different ${key} values by SDK and architecture: ${variants + .map(({ context, resolution }) => `${context.label}=${resolutionDisplay(resolution)}`) + .join(", ")}`, + remedy: + "Make device and simulator architecture values consistent or select the intended SDK and architecture explicitly.", + evidence: variants.flatMap(({ resolution }) => resolution.evidence), + }); + } return { state: "unresolved", @@ -762,6 +798,7 @@ function missingConfiguration( root: string, projectPath: string, configurationId: string, + platform: IOSNativePlatform = "ios", ): InspectedTargetConfiguration { const evidence: IOSSourceEvidence = { path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), @@ -778,9 +815,13 @@ function missingConfiguration( deploymentTarget: missing, }, entitlementContexts: [], - // The product type identifies this as an application target, but the - // dangling configuration does not contain enough evidence to exclude iOS. - isIOS: true, + supportedPlatforms: [], + unmodeledPlatforms: [], + // Preserve the selected fail-closed platform view for a dangling + // application configuration whose evidence cannot be resolved. + platform, + platformEvidenceComplete: false, + isIOS: platform === "ios", }; } @@ -794,6 +835,8 @@ export async function inspectTargetBuildConfigurations(options: { objects: PbxObjects; parents: PbxParentIndex; diagnostics: IOSDiagnostic[]; + /** Resolve settings through one platform view while preserving all declared platforms. */ + platform?: IOSNativePlatform; }): Promise { const { root, @@ -805,6 +848,7 @@ export async function inspectTargetBuildConfigurations(options: { objects, parents, diagnostics, + platform: requestedPlatform, } = options; const projectDirectory = dirname(projectPath); const pbxprojRelativePath = relativeIOSPath(root, resolve(projectPath, "project.pbxproj")); @@ -835,7 +879,9 @@ export async function inspectTargetBuildConfigurations(options: { )) { const targetConfig = targetReference.object; if (!targetConfig) { - inspected.push(missingConfiguration(root, projectPath, targetReference.id)); + inspected.push( + missingConfiguration(root, projectPath, targetReference.id, requestedPlatform ?? "ios"), + ); continue; } const name = asString(targetConfig.name) ?? "Unnamed"; @@ -901,35 +947,181 @@ export async function inspectTargetBuildConfigurations(options: { }); } - const deviceContext = evaluatedContexts[0]; - if (!deviceContext) continue; + if (evaluatedContexts.length === 0) continue; const evidence = (setting: string): IOSSourceEvidence => ({ path: pbxprojRelativePath, objectId: targetId, keyPath: `buildConfigurations.${name}.buildSettings.${setting}`, }); - const supportedPlatformsResolution = resolveSettingAcrossContexts( + // Select the automation platform without emitting cross-platform + // conflicts. Once selected, every inspected setting is resolved only + // across that platform's device/architecture contexts. + const initialSupportedPlatformsResolution = resolveSettingAcrossContexts( "SUPPORTED_PLATFORMS", evaluatedContexts, evidence("SUPPORTED_PLATFORMS"), targetName, name, diagnostics, + false, ); const supportedPlatforms = - supportedPlatformsResolution.state === "resolved" ? supportedPlatformsResolution.value : ""; + initialSupportedPlatformsResolution.state === "resolved" + ? initialSupportedPlatformsResolution.value + : ""; + const initialMacCatalystResolution = resolveSettingAcrossContexts( + "SUPPORTS_MACCATALYST", + evaluatedContexts, + evidence("SUPPORTS_MACCATALYST"), + targetName, + name, + diagnostics, + false, + ); + const normalizedMacCatalystValue = + initialMacCatalystResolution.state === "resolved" + ? initialMacCatalystResolution.value.trim().toUpperCase() + : undefined; + const macCatalystResolution: IOSValueResolution = + initialMacCatalystResolution.state === "resolved" && + normalizedMacCatalystValue !== "YES" && + normalizedMacCatalystValue !== "NO" + ? { + state: "unresolved", + raw: initialMacCatalystResolution.value, + missingVariables: ["invalid Boolean value"], + evidence: initialMacCatalystResolution.evidence, + } + : initialMacCatalystResolution; const supportedPlatformTokens = new Set( supportedPlatforms .toLowerCase() .split(/\s+/) .filter((value) => value !== ""), ); - const hasModeledIOSPlatform = + const hasIOSPlatform = supportedPlatformTokens.has("iphoneos") || supportedPlatformTokens.has("iphonesimulator"); - const activeContexts = - supportedPlatformsResolution.state === "resolved" && hasModeledIOSPlatform - ? evaluatedContexts.filter(({ context }) => supportedPlatformTokens.has(context.sdk)) - : evaluatedContexts; + const hasMacOSPlatform = supportedPlatformTokens.has("macosx"); + const declaredPlatforms: IOSNativePlatform[] = [ + ...(hasIOSPlatform ? (["ios"] as const) : []), + ...(hasMacOSPlatform ? (["macos"] as const) : []), + ]; + const supportedPlatform: IOSNativePlatform | undefined = + requestedPlatform ?? (hasIOSPlatform ? "ios" : hasMacOSPlatform ? "macos" : undefined); + const platformCandidateContexts = supportedPlatform + ? evaluatedContexts.filter(({ context }) => context.platform === supportedPlatform) + : evaluatedContexts; + const initialSDKRootResolution = resolveSettingAcrossContexts( + "SDKROOT", + platformCandidateContexts, + evidence("SDKROOT"), + targetName, + name, + diagnostics, + false, + ); + const initialSDKRoot = + initialSDKRootResolution.state === "resolved" + ? initialSDKRootResolution.value.toLowerCase() + : ""; + const sdkRootIsAuto = initialSDKRoot === "auto"; + const hasIOSSDK = /iphone(?:os|simulator)/.test(initialSDKRoot); + const hasMacOSSDK = initialSDKRoot.includes("macosx"); + const hasIOSCapabilityEvidence = hasIOSPlatform || hasIOSSDK; + const supportsMacCatalyst = + hasIOSCapabilityEvidence && + macCatalystResolution.state === "resolved" && + normalizedMacCatalystValue === "YES"; + const unmodeledPlatforms = [ + ...new Set([ + ...[...supportedPlatformTokens].filter( + (token) => !MODELED_SUPPORTED_PLATFORM_TOKENS.has(token), + ), + ...(supportsMacCatalyst ? ["maccatalyst"] : []), + ]), + ].sort(); + const hasUnknownPlatformEvidence = + initialSDKRootResolution.state === "unresolved" || + initialSupportedPlatformsResolution.state === "unresolved" || + (hasIOSCapabilityEvidence && macCatalystResolution.state === "unresolved"); + const hasResolvedUnsupportedEvidence = + (initialSDKRootResolution.state === "resolved" && + initialSDKRoot !== "" && + !sdkRootIsAuto && + !hasIOSSDK && + !hasMacOSSDK) || + (initialSupportedPlatformsResolution.state === "resolved" && + supportedPlatforms !== "" && + !hasIOSPlatform && + !hasMacOSPlatform); + // Existing iOS-capable multiplatform targets intentionally retain the iOS + // setup path by default. A capability planner may request a macOS view; + // unknown or contradictory evidence stays incomplete so mutations refuse. + const supportedClassification: IOSNativePlatform | "unsupported" | undefined = + initialSupportedPlatformsResolution.state === "resolved" && supportedPlatforms !== "" + ? requestedPlatform + ? declaredPlatforms.includes(requestedPlatform) + ? requestedPlatform + : "unsupported" + : (supportedPlatform ?? "unsupported") + : undefined; + const sdkClassification: IOSNativePlatform | "unsupported" | undefined = + initialSDKRootResolution.state === "resolved" && initialSDKRoot !== "" && !sdkRootIsAuto + ? hasIOSSDK + ? "ios" + : hasMacOSSDK + ? "macos" + : "unsupported" + : undefined; + const concreteClassifications = new Set( + [supportedClassification, sdkClassification].filter( + (value): value is IOSNativePlatform | "unsupported" => value !== undefined, + ), + ); + const hasMixedUnmodeledPlatforms = + (declaredPlatforms.length > 0 && unmodeledPlatforms.length > 0) || supportsMacCatalyst; + const platformEvidenceComplete = requestedPlatform + ? concreteClassifications.size === 1 && + concreteClassifications.has(requestedPlatform) && + !hasUnknownPlatformEvidence && + !hasMixedUnmodeledPlatforms + : concreteClassifications.size === 1 && + !hasUnknownPlatformEvidence && + !hasMixedUnmodeledPlatforms; + const platform: IOSNativePlatform | undefined = requestedPlatform + ? requestedPlatform + : concreteClassifications.has("ios") + ? "ios" + : concreteClassifications.has("macos") + ? "macos" + : hasUnknownPlatformEvidence + ? (requestedPlatform ?? "ios") + : concreteClassifications.has("unsupported") + ? undefined + : !hasResolvedUnsupportedEvidence + ? (requestedPlatform ?? "ios") + : undefined; + const supportedNativePlatforms: IOSNativePlatform[] = [ + ...declaredPlatforms, + ...(sdkClassification === "ios" || sdkClassification === "macos" ? [sdkClassification] : []), + ].filter((value, index, values): value is IOSNativePlatform => values.indexOf(value) === index); + const platformContexts = platform + ? evaluatedContexts.filter(({ context }) => context.platform === platform) + : evaluatedContexts; + const hasExplicitContextFilter = + initialSupportedPlatformsResolution.state === "resolved" && + platformContexts.some(({ context }) => supportedPlatformTokens.has(context.sdk)); + const activeContexts = hasExplicitContextFilter + ? platformContexts.filter(({ context }) => supportedPlatformTokens.has(context.sdk)) + : platformContexts; + const supportedPlatformsResolution = resolveSettingAcrossContexts( + "SUPPORTED_PLATFORMS", + activeContexts, + evidence("SUPPORTED_PLATFORMS"), + targetName, + name, + diagnostics, + ); const sdkRootResolution = resolveSettingAcrossContexts( "SDKROOT", activeContexts, @@ -938,32 +1130,16 @@ export async function inspectTargetBuildConfigurations(options: { name, diagnostics, ); + const deploymentTargetSetting = + platform === "macos" ? "MACOSX_DEPLOYMENT_TARGET" : "IPHONEOS_DEPLOYMENT_TARGET"; const deploymentTarget = resolveSettingAcrossContexts( - "IPHONEOS_DEPLOYMENT_TARGET", + deploymentTargetSetting, activeContexts, - evidence("IPHONEOS_DEPLOYMENT_TARGET"), + evidence(deploymentTargetSetting), targetName, name, diagnostics, ); - const sdkRoot = sdkRootResolution.state === "resolved" ? sdkRootResolution.value : ""; - const hasIOSSDK = sdkRootResolution.state === "resolved" && sdkRoot.includes("iphoneos"); - const hasIOSPlatform = - supportedPlatformsResolution.state === "resolved" && - /iphone(?:os|simulator)/.test(supportedPlatforms); - const hasUnknownPlatformEvidence = - sdkRootResolution.state === "unresolved" || - supportedPlatformsResolution.state === "unresolved"; - const hasResolvedNonIOSEvidence = - (sdkRootResolution.state === "resolved" && sdkRoot !== "" && !hasIOSSDK) || - (supportedPlatformsResolution.state === "resolved" && - supportedPlatforms !== "" && - !hasIOSPlatform); - // SDKROOT and SUPPORTED_PLATFORMS describe the target platform directly. - // IPHONEOS_DEPLOYMENT_TARGET can remain as a stale setting on a non-iOS - // target, so it must not override resolved platform evidence. - const explicitlyNonIOS = - !hasUnknownPlatformEvidence && !hasIOSSDK && !hasIOSPlatform && hasResolvedNonIOSEvidence; const model: IOSBuildConfiguration = { name, @@ -992,15 +1168,44 @@ export async function inspectTargetBuildConfigurations(options: { diagnostics, ), deploymentTarget, + ...(platform === "macos" + ? { + appSandbox: resolveSettingAcrossContexts( + "ENABLE_APP_SANDBOX", + activeContexts, + evidence("ENABLE_APP_SANDBOX"), + targetName, + name, + diagnostics, + ), + outgoingNetworkConnections: resolveSettingAcrossContexts( + "ENABLE_OUTGOING_NETWORK_CONNECTIONS", + activeContexts, + evidence("ENABLE_OUTGOING_NETWORK_CONNECTIONS"), + targetName, + name, + diagnostics, + ), + } + : {}), }; const relevantSettings: Array<[string, IOSValueResolution]> = [ ["PRODUCT_BUNDLE_IDENTIFIER", model.bundleIdentifier], ["DEVELOPMENT_TEAM", model.developmentTeam], ["CODE_SIGN_ENTITLEMENTS", model.entitlementsPath], - ["IPHONEOS_DEPLOYMENT_TARGET", model.deploymentTarget], + [deploymentTargetSetting, model.deploymentTarget], ["SDKROOT", sdkRootResolution], ["SUPPORTED_PLATFORMS", supportedPlatformsResolution], ]; + if (hasIOSCapabilityEvidence) { + relevantSettings.push(["SUPPORTS_MACCATALYST", macCatalystResolution]); + } + if (platform === "macos") { + relevantSettings.push( + ["ENABLE_APP_SANDBOX", model.appSandbox!], + ["ENABLE_OUTGOING_NETWORK_CONNECTIONS", model.outgoingNetworkConnections!], + ); + } for (const [setting, resolution] of relevantSettings) { if (resolution.state !== "unresolved") continue; addDiagnosticOnce(diagnostics, { @@ -1022,7 +1227,11 @@ export async function inspectTargetBuildConfigurations(options: { globalTaintOverrides: new Set(evaluation.globalTaintOverrides), builtins: { ...builtins }, })), - isIOS: !explicitlyNonIOS, + supportedPlatforms: supportedNativePlatforms, + unmodeledPlatforms, + platform, + platformEvidenceComplete, + isIOS: platform === "ios", }); } diff --git a/packages/cli-core/src/commands/init/ios/coordinator.ts b/packages/cli-core/src/commands/init/ios/coordinator.ts index 037420f07..bdd542446 100644 --- a/packages/cli-core/src/commands/init/ios/coordinator.ts +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -23,6 +23,7 @@ import { inspectIOSProject } from "./inspect.ts"; import { type IOSLocalSetupResult } from "./apply.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./output.ts"; import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.ts"; +import { iosPlatformViewsIdentityMatches, reinspectIOSPlatformViews } from "./platform-views.ts"; type LinkedProfile = Awaited>; @@ -64,6 +65,7 @@ export type AppleNativeSetupCoordinator = { linkedProfile: LinkedProfile; validatedAgentAuthLabel?: string; preauthenticatedLabel?: string; + frameworkName: string; targetName: string; requiresLinkedApp: boolean; requiresExplicitApplication: boolean; @@ -103,6 +105,7 @@ export async function runAppleNativeDryRun(options: AppleNativeDryRunOptions): P createIOSDryRunOutput(inspection, plan, { associatedDomainPlan, nativeReadiness: proposal.nativeReadiness, + platformViews: proposal.platformViews, }), null, 2, @@ -113,6 +116,7 @@ export async function runAppleNativeDryRun(options: AppleNativeDryRunOptions): P formatIOSSetupPlan(inspection, plan, { associatedDomainPlan, nativeReadiness: proposal.nativeReadiness, + platformViews: proposal.platformViews, }), ); await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); @@ -137,7 +141,7 @@ export async function prepareAppleNativeSetup( } if (options.agent && validatedAgentAuthLabel === null) { throwUsageError( - "Native iOS setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", + "Native Apple setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", ); } @@ -153,7 +157,7 @@ export async function prepareAppleNativeSetup( }); if (options.agent && localSetup.requiresExplicitApplication && !options.requestedApplicationId) { throwUsageError( - "This iOS target already contains a publishable-key configuration that requires explicit Clerk application selection. Ask the developer which existing application it belongs to, then rerun with --app . No local files were changed.", + "This native Apple target already contains a publishable-key configuration that requires explicit Clerk application selection. Ask the developer which existing application it belongs to, then rerun with --app . No local files were changed.", ); } @@ -162,6 +166,7 @@ export async function prepareAppleNativeSetup( linkedProfile, validatedAgentAuthLabel: authLabel, preauthenticatedLabel: options.agent ? authLabel : undefined, + frameworkName: localSetup.platform === "macos" ? "macOS (Swift)" : "iOS (Swift)", targetName: localSetup.targetName, requiresLinkedApp: localSetup.requiresLinkedApp, requiresExplicitApplication: localSetup.requiresExplicitApplication, @@ -207,7 +212,7 @@ async function completeAppleNativeSetup( } if (!options.authenticationCompleted) { throw new CliError( - "The approved iOS configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", + "The approved native Apple configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", { code: ERROR_CODE.NOT_LINKED }, ); } @@ -224,7 +229,7 @@ async function completeAppleNativeSetup( ); if (keys.applicationId !== options.applicationId) { throw new CliError( - "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", + "The linked Clerk application changed while its native publishable key was being resolved. No local setup changes were written; rerun clerk init.", { code: ERROR_CODE.IOS_SETUP_STALE }, ); } @@ -287,7 +292,7 @@ async function completeAppleNativeSetup( const target = localSetup.nativeReadiness.target; if (target.status !== "selected" || target.bundleIdentifier.status !== "resolved") { throw new CliError( - "The selected iOS Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", + "The selected Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", { code: ERROR_CODE.IOS_TARGET_UNRESOLVED }, ); } @@ -301,6 +306,7 @@ async function completeAppleNativeSetup( const preparedApple = await prepareIOSNativeAppleConnection({ applicationId: keys.applicationId, instanceId: keys.instanceId, + platform: target.platform, bundleIdentifier: nativeRemotePlan.bundleIdentifier, nativeApplicationReady: nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", @@ -320,7 +326,7 @@ async function completeAppleNativeSetup( const commitProfile = await resolveProfile(preparation.root); if (commitProfile?.profile.appId !== options.applicationId) { throw new CliError( - "The local Clerk application link changed before the approved iOS setup could be committed. No local or remote setup changes were written; rerun clerk init.", + "The local Clerk application link changed before the approved native Apple setup could be committed. No local or remote setup changes were written; rerun clerk init.", { code: ERROR_CODE.IOS_SETUP_STALE }, ); } @@ -340,7 +346,7 @@ async function completeAppleNativeSetup( } if (authEnvironment.apple !== inspectedAuthViewAppleRequirement) { throw new CliError( - "The linked Clerk application's AuthView methods changed while the approved iOS setup was being prepared. No local or remote setup changes were written; rerun clerk init.", + "The linked Clerk application's AuthView methods changed while the approved native Apple setup was being prepared. No local or remote setup changes were written; rerun clerk init.", { code: ERROR_CODE.IOS_SETUP_STALE }, ); } @@ -370,30 +376,44 @@ async function completeAppleNativeSetup( setupForCommit, setupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, ); - await assertApplicationLinkStillMatches({ - root: preparation.root, - applicationId: nativeRemotePlan.applicationId, - phase: "native-application", - }); + const revalidateNativeApplicationPreconditions = async (): Promise => { + await assertPlatformIdentityStillMatches(setupForCommit.platformViews, "native-application"); + await assertApplicationLinkStillMatches({ + root: preparation.root, + applicationId: nativeRemotePlan.applicationId, + phase: "native-application", + }); + }; + await revalidateNativeApplicationPreconditions(); await applyRemoteStep( "ios_native_setup", - async () => applyIOSNativeRemoteSetup(nativeRemotePlan), + async () => + applyIOSNativeRemoteSetup(nativeRemotePlan, { + revalidateLocalPreconditions: revalidateNativeApplicationPreconditions, + }), "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", - "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", + "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.", ); - log.success("Clerk Native API and iOS application registration verified"); + log.success("Clerk Native API and application registration verified"); if (nativeApplePlan) { - await assertApplicationLinkStillMatches({ - root: preparation.root, - applicationId: nativeApplePlan.applicationId, - phase: "native-apple", - }); + const revalidateNativeApplePreconditions = async (): Promise => { + await assertPlatformIdentityStillMatches(setupForCommit.platformViews, "native-apple"); + await assertApplicationLinkStillMatches({ + root: preparation.root, + applicationId: nativeApplePlan.applicationId, + phase: "native-apple", + }); + }; + await revalidateNativeApplePreconditions(); await applyRemoteStep( "ios_apple_setup", - async () => applyIOSNativeAppleConnection(nativeApplePlan), + async () => + applyIOSNativeAppleConnection(nativeApplePlan, { + revalidateLocalPreconditions: revalidateNativeApplePreconditions, + }), "Could not reconcile the native Apple connection; underlying error details were omitted.", - "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", + "The local native Apple setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", ); } @@ -456,7 +476,23 @@ async function assertApplicationLinkStillMatches(options: { const message = options.phase === "native-application" - ? "The local Clerk application link changed after the approved iOS setup was committed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." + ? "The local Clerk application link changed after the approved native Apple setup was committed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." : "The local Clerk application link changed after Clerk Native Application setup completed. The completed local and Clerk Native Application changes remain intact, but no native Apple connection changes were made; rerun clerk init."; throw new CliError(message, { code: ERROR_CODE.IOS_SETUP_STALE }); } + +async function assertPlatformIdentityStillMatches( + approved: IOSLocalSetupResult["platformViews"], + phase: "native-application" | "native-apple", +): Promise { + const current = await reinspectIOSPlatformViews(approved); + if (current.status === "ready" && iosPlatformViewsIdentityMatches(approved, current.snapshot)) { + return; + } + + const message = + phase === "native-application" + ? "The selected target's supported platforms or native identity changed after the local setup completed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." + : "The selected target's supported platforms or native identity changed after Clerk Native Application setup completed. Completed local and registration changes remain intact, but no native Apple connection changes were made; rerun clerk init."; + throw new CliError(message, { code: ERROR_CODE.IOS_SETUP_STALE }); +} diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 654d56f56..d1ade23b6 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -126,6 +126,24 @@ afterEach(async () => { }); describe("iOS direct Clerk configuration", () => { + test("plans the same direct SwiftUI configuration for a macOS app", async () => { + const root = await fixture({ platform: "macos" }); + + const plan = await planIOSDirectConfig({ ...planOptions(root), platform: "macos" }); + + expect(plan).toMatchObject({ + status: "ready", + platform: "macos", + sourcePath: "MyApp/MyAppApp.swift", + changes: { + clerkKitImport: "insert", + configuration: "insert-initializer", + environment: "insert", + }, + blockers: [], + }); + }); + test("plans a fully redacted pristine SwiftUI setup without writing", async () => { const root = await fixture(); const before = await treeDigest(root); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index e4cd53e5a..4c995aa85 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -21,6 +21,7 @@ import { type SwiftUIRootExpression, } from "./swift-app-root.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; +import type { IOSNativePlatform } from "./types.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -29,6 +30,7 @@ export interface IOSDirectConfigPlanOptions { /** Project-root-relative path selected by the iOS inspector. */ projectPath: string; targetId: string; + platform?: IOSNativePlatform; /** Low-level escape hatch. The aggregate init flow also checks every local mutation. */ allowDirty?: boolean; } @@ -38,6 +40,7 @@ export type IOSDirectConfigBlockerCode = | "external-path" | "generated-project" | "target-not-found" + | "unresolved-platform" | "incomplete-source-membership" | "shared-source" | "ambiguous-entry-point" @@ -81,6 +84,7 @@ export interface IOSDirectConfigPlan { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; allowDirty: boolean; sourcePath?: string; /** SHA-256 of the exact source bytes inspected by this plan. */ @@ -164,8 +168,18 @@ interface AppStructure { existingPublishableKey?: string; hasEnvironment: boolean; configurationInsertion: - | { kind: "new-initializer"; index: number; memberIndent: string; statementIndent: string } - | { kind: "existing-initializer"; index: number; statementIndent: string; multiline: boolean } + | { + kind: "new-initializer"; + index: number; + memberIndent: string; + statementIndent: string; + } + | { + kind: "existing-initializer"; + index: number; + statementIndent: string; + multiline: boolean; + } | { kind: "existing-literal" }; environmentInsertion?: { index: number; textBeforeKey: string }; } @@ -206,6 +220,7 @@ function makePlan( root, projectPath, targetId: options.targetId, + platform: options.platform ?? "ios", allowDirty: options.allowDirty === true, sourcePath: details.sourcePath, expectedSourceHash: details.expectedSourceHash, @@ -1089,6 +1104,7 @@ async function prepareDirectConfig( const projectPath = relativeIOSPath(root, absoluteProjectPath); const inspection = await inspectIOSProject(root, { target: options.targetId, + platform: options.platform, exhaustiveContainerDiscovery: true, }); if (hasIncompleteIOSContainerDiscovery(inspection)) { @@ -1110,7 +1126,7 @@ async function prepareDirectConfig( root, projectPath, "target-not-found", - "The selected native iOS application target could not be proven.", + "The selected native Apple application target could not be proven.", ); } const generator = @@ -1121,7 +1137,9 @@ async function prepareDirectConfig( root, projectPath, "generated-project", - `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated Swift sources.`, + `This is a ${ + generator === "xcodegen" ? "XcodeGen" : "Tuist" + } project; update its source manifest instead of generated Swift sources.`, ); } const target = inspection.appTargets.find( @@ -1133,7 +1151,25 @@ async function prepareDirectConfig( root, projectPath, "target-not-found", - "The selected native iOS application target disappeared during inspection.", + "The selected native Apple application target disappeared during inspection.", + ); + } + if (!target.platformEvidenceComplete) { + return blocked( + options, + root, + projectPath, + "unresolved-platform", + "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing Swift startup code.", + ); + } + if (options.platform && target.platform !== options.platform) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected application target changed platforms during inspection.", ); } if (!target.swift.evidenceComplete) { @@ -1343,7 +1379,12 @@ function redactedKeyBlocker( code: IOSDirectConfigBlockerCode, message: string, ): IOSDirectConfigPlan { - return { ...plan, status: "blocked", actions: [], blockers: [{ code, message }] }; + return { + ...plan, + status: "blocked", + actions: [], + blockers: [{ code, message }], + }; } function mutationWithHiddenBytes( @@ -1371,7 +1412,10 @@ function readyPreparedMutation( validator: () => Promise, ): IOSDirectConfigPreparedMutation { const prepared = { status: "ready", plan } as IOSDirectConfigPreparedMutation; - Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + Object.defineProperty(prepared, "mutation", { + value: mutation, + enumerable: false, + }); preparedValidators.set(prepared, validator); return prepared; } @@ -1385,6 +1429,7 @@ async function exactPostcondition( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowDirty: true, }); return ( @@ -1421,7 +1466,7 @@ export async function prepareIOSDirectConfigMutation( plan: redactedKeyBlocker( plan, "invalid-selection", - "The direct iOS configuration plan is incomplete or unsupported.", + "The direct native Apple configuration plan is incomplete or unsupported.", ), }; } @@ -1439,7 +1484,7 @@ export async function prepareIOSDirectConfigMutation( plan, production ? "production-publishable-key" : "invalid-publishable-key", production - ? "Automatic direct iOS configuration accepts a development publishable key only." + ? "Automatic direct native Apple configuration accepts a development publishable key only." : "A valid Clerk development publishable key is required.", ), }; @@ -1449,6 +1494,7 @@ export async function prepareIOSDirectConfigMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowDirty: plan.allowDirty, }); if ( @@ -1550,7 +1596,7 @@ export async function applyIOSDirectConfig( return { status: "rolled-back", plan, - message: "The direct iOS source update failed and the original file was restored.", + message: "The direct native Apple source update failed and the original file was restored.", }; } @@ -1561,6 +1607,6 @@ export async function applyIOSDirectConfig( message: result.status === "stale" ? "The selected Swift entry source changed while the update was being committed." - : "The direct iOS source update failed validation and the original file was restored.", + : "The direct native Apple source update failed validation and the original file was restored.", }; } diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index c0c9fdac6..d7d63e39b 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -601,7 +601,7 @@ struct MyApp: App { (step: { id: string }) => step.id === "add-authentication-flow", ); expect(auth).toMatchObject({ status: "blocked", automatable: false }); - expect(auth.description).toContain("require iOS 17.0 or newer"); + expect(auth.description).toContain("requires iOS 17.0 or newer"); expect(auth.description).toContain("IPHONEOS_DEPLOYMENT_TARGET"); expect(requestCount).toBe(0); expect(await treeDigest(root)).toEqual(before); 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 37472eff3..585e01ffa 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -22,11 +22,12 @@ import { type PbxObject, type PbxObjects, } from "./pbx.ts"; -import type { IOSDiagnostic } from "./types.ts"; +import type { IOSDiagnostic, IOSNativePlatform } from "./types.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const DEVICE_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"; const SIMULATOR_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"; +const MACOS_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"; const MAX_PBXPROJ_BYTES = 15_000_000; export interface IOSMissingEntitlementsSettingsOptions { @@ -34,6 +35,8 @@ export interface IOSMissingEntitlementsSettingsOptions { /** Invocation-root-relative selected .xcodeproj path. */ projectPath: string; targetId: string; + /** Defaults to iOS for existing callers. */ + platform?: IOSNativePlatform; } export type IOSMissingEntitlementsSettingsBlockerCode = @@ -67,6 +70,7 @@ interface IOSMissingEntitlementsSettingsPlanBase { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; /** Exact target configuration IDs authorized by this plan, when inspectable. */ configurationIds: string[]; actions: string[]; @@ -151,7 +155,7 @@ function planBase( options: IOSMissingEntitlementsSettingsOptions, ): Pick< IOSMissingEntitlementsSettingsPlanBase, - "schemaVersion" | "kind" | "root" | "projectPath" | "targetId" + "schemaVersion" | "kind" | "root" | "projectPath" | "targetId" | "platform" > { return { schemaVersion: 1, @@ -159,6 +163,7 @@ function planBase( root: resolve(options.root), projectPath: options.projectPath.replaceAll("\\", "/"), targetId: options.targetId, + platform: options.platform ?? "ios", }; } @@ -685,9 +690,18 @@ async function entitlementsDestinationIsExclusive( projectPaths: readonly string[], selectedProjectPath: string, selectedTargetId: string, + selectedPlatform: IOSNativePlatform, destination: string, ): Promise { const normalizedDestination = resolve(destination).toLocaleLowerCase("en-US"); + let canonicalDestination: string; + try { + canonicalDestination = ( + await canonicalPathWithPossibleMissingLeaf(destination) + ).toLocaleLowerCase("en-US"); + } catch { + return false; + } for (const absoluteProjectPath of projectPaths) { const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; @@ -712,12 +726,11 @@ async function entitlementsDestinationIsExclusive( asString(projectObject.projectDirPath) ?? "", ); for (const targetId of targetIds) { - if (absoluteProjectPath === selectedProjectPath && targetId === selectedTargetId) continue; const targetObject = objects[targetId]; if (!targetObject) return false; if (targetObject.isa !== "PBXNativeTarget") continue; - const diagnostics: IOSDiagnostic[] = []; - const configurations = await inspectTargetBuildConfigurations({ + const primaryDiagnostics: IOSDiagnostic[] = []; + const primaryConfigurations = await inspectTargetBuildConfigurations({ root, projectPath: absoluteProjectPath, groupRootDirectory, @@ -726,21 +739,84 @@ async function entitlementsDestinationIsExclusive( targetObject, objects, parents, - diagnostics, + diagnostics: primaryDiagnostics, }); if ( - configurations.length === 0 || - diagnostics.some((diagnostic) => diagnostic.severity === "error") + primaryConfigurations.length === 0 || + primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || + primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") ) { return false; } - for (const configuration of configurations) { - const resolution = configuration.model.entitlementsPath; - if (resolution.state === "unresolved") return false; - if (resolution.state !== "resolved") continue; - const siblingPath = resolve(dirname(absoluteProjectPath), resolution.value); - if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; - if (siblingPath.toLocaleLowerCase("en-US") === normalizedDestination) return false; + const primaryPlatforms = new Set( + primaryConfigurations + .map((configuration) => configuration.platform) + .filter((platform): platform is IOSNativePlatform => platform !== undefined), + ); + if (primaryPlatforms.size > 1) return false; + const primaryPlatform = [...primaryPlatforms][0]; + const supportedPlatforms = new Set( + primaryConfigurations.flatMap((configuration) => configuration.supportedPlatforms), + ); + 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: absoluteProjectPath, + groupRootDirectory, + projectObject, + targetId, + targetObject, + objects, + parents, + diagnostics, + platform, + }); + if ( + configurations.length !== primaryConfigurations.length || + configurations.length === 0 || + 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 ( + absoluteProjectPath === selectedProjectPath && + targetId === selectedTargetId && + view.platform === selectedPlatform + ) { + 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(absoluteProjectPath), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + if (siblingPath.toLocaleLowerCase("en-US") === normalizedDestination) return false; + try { + if ( + (await canonicalPathWithPossibleMissingLeaf(siblingPath)).toLocaleLowerCase( + "en-US", + ) === canonicalDestination + ) { + return false; + } + } catch { + return false; + } + } } } } @@ -760,6 +836,8 @@ function destinationForRoot( root: string, absoluteProjectPath: string, synchronizedRoot: SynchronizedRoot, + platform: IOSNativePlatform, + platformSpecific: boolean, ): | { absolutePath: string; relativePath: string; buildSettingPath: string } | { blocker: IOSMissingEntitlementsSettingsBlocker } { @@ -778,7 +856,8 @@ function destinationForRoot( ), }; } - const absolutePath = resolve(synchronizedRoot.absolutePath, `${rootName}.entitlements`); + const suffix = platform === "macos" && platformSpecific ? ".mac.entitlements" : ".entitlements"; + const absolutePath = resolve(synchronizedRoot.absolutePath, `${rootName}${suffix}`); const projectDirectory = dirname(absoluteProjectPath); const buildSettingPath = relative(projectDirectory, absolutePath).split(sep).join("/"); if ( @@ -832,13 +911,19 @@ function settingsDictionary( return isRecord(settings) ? settings : undefined; } -function rawSettingsAreExact(graph: ProjectGraph, buildSettingPath: string): boolean { +function entitlementsSettingKeys(platform: IOSNativePlatform): readonly string[] { + return platform === "macos" ? [MACOS_SETTING] : [DEVICE_SETTING, SIMULATOR_SETTING]; +} + +function rawSettingsAreExact( + graph: ProjectGraph, + buildSettingPath: string, + platform: IOSNativePlatform, +): boolean { + const keys = entitlementsSettingKeys(platform); return graph.configurationIds.every((id) => { const settings = settingsDictionary(graph, id); - return ( - settings?.[DEVICE_SETTING] === buildSettingPath && - settings?.[SIMULATOR_SETTING] === buildSettingPath - ); + return keys.every((key) => settings?.[key] === buildSettingPath); }); } @@ -846,6 +931,7 @@ async function buildSettingState( root: string, snapshot: ProjectSnapshot, buildSettingPath: string, + platform: IOSNativePlatform, ): Promise<"missing" | "exact" | "conflicting" | "incomplete"> { const diagnostics: IOSDiagnostic[] = []; const parents = buildPbxParentIndex(snapshot.graph.objects); @@ -863,11 +949,13 @@ async function buildSettingState( objects: snapshot.graph.objects, parents, diagnostics, + platform, }); if ( inspected.length !== snapshot.graph.configurationIds.length || inspected.length === 0 || - !inspected.some((configuration) => configuration.isIOS) || + inspected.some((configuration) => !configuration.platformEvidenceComplete) || + !inspected.every((configuration) => configuration.platform === platform) || diagnostics.some((diagnostic) => diagnostic.severity === "error") ) { return "incomplete"; @@ -878,7 +966,7 @@ async function buildSettingState( return "missing"; } if ( - rawSettingsAreExact(snapshot.graph, buildSettingPath) && + rawSettingsAreExact(snapshot.graph, buildSettingPath, platform) && inspected.every( (configuration) => configuration.model.entitlementsPath.state === "resolved" && @@ -894,10 +982,20 @@ async function inspectSelectedTarget( root: string, projectPath: string, targetId: string, -): Promise { + platform: IOSNativePlatform, +): Promise< + | { + name: string; + platform: IOSNativePlatform; + supportedPlatforms: IOSNativePlatform[]; + platformEvidenceComplete: boolean; + } + | undefined +> { const inspection = await inspectIOSProject(root, { target: targetId, exhaustiveContainerDiscovery: true, + platform, }); if ( inspection.selection.state !== "selected" || @@ -906,9 +1004,17 @@ async function inspectSelectedTarget( ) { return undefined; } - return inspection.appTargets.find( + const target = inspection.appTargets.find( (target) => target.id === targetId && target.projectPath === projectPath, - )?.name; + ); + return target + ? { + name: target.name, + platform: target.platform, + supportedPlatforms: target.supportedPlatforms, + platformEvidenceComplete: target.platformEvidenceComplete, + } + : undefined; } export async function planIOSMissingEntitlementsSettings( @@ -916,7 +1022,12 @@ export async function planIOSMissingEntitlementsSettings( ): Promise { const root = resolve(options.root); const normalizedProjectPath = options.projectPath.replaceAll("\\", "/"); - const normalizedOptions = { ...options, root, projectPath: normalizedProjectPath }; + const normalizedOptions = { + ...options, + root, + projectPath: normalizedProjectPath, + platform: options.platform ?? "ios", + }; if (!validSuppliedSelection(normalizedOptions)) { return blockedPlan( normalizedOptions, @@ -947,21 +1058,46 @@ export async function planIOSMissingEntitlementsSettings( ), ); } - const targetName = await inspectSelectedTarget(root, normalizedProjectPath, options.targetId); - if (!targetName) { + const selectedTarget = await inspectSelectedTarget( + root, + normalizedProjectPath, + options.targetId, + normalizedOptions.platform, + ); + if ( + !selectedTarget || + selectedTarget.platform !== normalizedOptions.platform || + !selectedTarget.supportedPlatforms.includes(normalizedOptions.platform) + ) { return blockedPlan( normalizedOptions, blocker( "target-not-found", - "The selected object is not the exact inspected native iOS application target.", + `The selected object is not the exact inspected native ${normalizedOptions.platform === "macos" ? "macOS" : "iOS"} application target.`, + ), + { + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.graph.configurationIds, + }, + ); + } + if (!selectedTarget.platformEvidenceComplete) { + return blockedPlan( + normalizedOptions, + blocker( + "incomplete-build-configurations", + `Every selected-target build configuration must prove ${normalizedOptions.platform === "macos" ? "macOS" : "iOS"} support before adding entitlements settings.`, ), { + targetName: selectedTarget.name, expectedPbxprojHash: snapshot.hash, expectedPbxprojMode: snapshot.mode, configurationIds: snapshot.graph.configurationIds, }, ); } + const targetName = selectedTarget.name; const synchronized = await selectedSynchronizedRoot(root, snapshot); if (!synchronized.root) { return blockedPlan(normalizedOptions, synchronized.blocker!, { @@ -971,7 +1107,13 @@ export async function planIOSMissingEntitlementsSettings( configurationIds: snapshot.graph.configurationIds, }); } - const destination = destinationForRoot(root, snapshot.absoluteProjectPath, synchronized.root); + const destination = destinationForRoot( + root, + snapshot.absoluteProjectPath, + synchronized.root, + normalizedOptions.platform, + normalizedOptions.platform === "macos" && selectedTarget.supportedPlatforms.includes("ios"), + ); if ("blocker" in destination) { return blockedPlan(normalizedOptions, destination.blocker, { targetName, @@ -1089,6 +1231,7 @@ export async function planIOSMissingEntitlementsSettings( inventory.projectPaths, snapshot.absoluteProjectPath, options.targetId, + normalizedOptions.platform, destination.absolutePath, )) ) { @@ -1114,7 +1257,12 @@ export async function planIOSMissingEntitlementsSettings( }, ); } - const settingState = await buildSettingState(root, snapshot, destination.buildSettingPath); + const settingState = await buildSettingState( + root, + snapshot, + destination.buildSettingPath, + normalizedOptions.platform, + ); const sharedPlanFields: IOSMissingEntitlementsSettingsResolvedFields & { configurationIds: string[]; } = { @@ -1136,7 +1284,7 @@ export async function planIOSMissingEntitlementsSettings( normalizedOptions, blocker( "incomplete-build-configurations", - "Every selected-target build configuration and iOS build context must be inspectable before adding entitlements settings.", + `Every selected-target build configuration and ${normalizedOptions.platform === "macos" ? "macOS" : "iOS"} build context must be inspectable before adding entitlements settings.`, ), sharedPlanFields, ); @@ -1146,7 +1294,7 @@ export async function planIOSMissingEntitlementsSettings( normalizedOptions, blocker( "conflicting-entitlements-settings", - "The selected target already has partial, inherited, unresolved, or conflicting iOS entitlements settings.", + `The selected target already has partial, inherited, unresolved, or conflicting ${normalizedOptions.platform === "macos" ? "macOS" : "iOS"} entitlements settings.`, ), sharedPlanFields, ); @@ -1194,7 +1342,9 @@ export async function planIOSMissingEntitlementsSettings( settingState === "exact" ? [] : [ - `Add iOS device and simulator CODE_SIGN_ENTITLEMENTS settings for ${destination.relativePath} to every selected-target build configuration.`, + normalizedOptions.platform === "macos" + ? `Add a macOS CODE_SIGN_ENTITLEMENTS setting for ${destination.relativePath} to every selected-target build configuration.` + : `Add iOS device and simulator CODE_SIGN_ENTITLEMENTS settings for ${destination.relativePath} to every selected-target build configuration.`, ], blockers: [], }; @@ -1212,6 +1362,7 @@ function sameResolvedPlanIdentity( left.root === right.root && left.projectPath === right.projectPath && left.targetId === right.targetId && + left.platform === right.platform && left.entitlementsPath === right.entitlementsPath && left.buildSettingPath === right.buildSettingPath && left.synchronizedRootPath === right.synchronizedRootPath && @@ -1274,11 +1425,19 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( baseMutation?: IOSExistingFileMutation, ): Promise { if (plan.status === "blocked") return { status: "blocked", plan }; + if (plan.platform !== "ios" && plan.platform !== "macos") { + return blockPrepared( + plan, + "unsupported-project", + "The serialized entitlements-settings plan has no supported target platform.", + ); + } if (plan.status === "satisfied") { const current = await planIOSMissingEntitlementsSettings({ root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, }); if (current.status === "blocked") return { status: "blocked", plan: current }; return current.status === "satisfied" && sameResolvedPlanIdentity(plan, current) @@ -1350,6 +1509,7 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, }); if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; if ( @@ -1424,19 +1584,18 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( "A selected-target build configuration has no mutable build-settings dictionary.", ); } - const device = settings[DEVICE_SETTING]; - const simulator = settings[SIMULATOR_SETTING]; - const absent = device == null && simulator == null; - const exact = device === plan.buildSettingPath && simulator === plan.buildSettingPath; + const settingKeys = entitlementsSettingKeys(plan.platform); + const values = settingKeys.map((key) => settings[key]); + const absent = values.every((value) => value == null); + const exact = values.every((value) => value === plan.buildSettingPath); if (!absent && !exact) { return blockPrepared( plan, "conflicting-entitlements-settings", - "The prepared Xcode candidate introduced partial or conflicting iOS entitlements settings.", + `The prepared Xcode candidate introduced partial or conflicting ${plan.platform === "macos" ? "macOS" : "iOS"} entitlements settings.`, ); } - settings[DEVICE_SETTING] = plan.buildSettingPath; - settings[SIMULATOR_SETTING] = plan.buildSettingPath; + for (const key of settingKeys) settings[key] = plan.buildSettingPath; } let candidate: string; @@ -1462,12 +1621,12 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( if ( !candidateGraph || !sameStringArray(candidateGraph.configurationIds, plan.configurationIds) || - !rawSettingsAreExact(candidateGraph, plan.buildSettingPath) + !rawSettingsAreExact(candidateGraph, plan.buildSettingPath, plan.platform) ) { return blockPrepared( plan, "unsupported-project", - "The proposed Xcode project did not retain every required iOS entitlements setting.", + `The proposed Xcode project did not retain every required ${plan.platform === "macos" ? "macOS" : "iOS"} entitlements setting.`, ); } const candidateBytes = new TextEncoder().encode(candidate); @@ -1490,6 +1649,7 @@ export async function validateIOSMissingEntitlementsSettingsPostcondition( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, }); return ( current.status === "satisfied" && diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index acd62a9f5..57c353d26 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -7,7 +7,13 @@ import { discoverIOSContainers, discoverLocalIOSProjects, inspectWorkspace } fro import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { recoverIOSFileTransactions } from "./file-transaction.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; +import { + addVisionOSDestinationsToFixture, + convertIOSFixtureToMultiplatform, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; const temporaryDirectories: string[] = []; const FILE_TRANSACTION_MODULE = `${import.meta.dir}/file-transaction.ts`; @@ -111,6 +117,37 @@ async function transformProjectAt( await Bun.write(projectPath, buildPbxProject(project)); } +async function addSynchronizedPlatformFilteredSources(root: string): Promise { + const synchronizedRootId = "404040404040404040404040"; + const exceptionId = "414141414141414141414141"; + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: [exceptionId], + path: "Synced", + sourceTree: "", + }; + objects[exceptionId] = { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + platformFiltersByRelativePath: { + "IOSOnly.swift": ["ios"], + "MacOnly.swift": ["macos"], + }, + target: IOS_FIXTURE_IDS.appTarget, + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + await mkdir(join(root, "Synced"), { recursive: true }); + await Bun.write( + join(root, "Synced", "IOSOnly.swift"), + "import ClerkKitUI\nstruct IOSOnly { let view = AuthView() }\n", + ); + await Bun.write( + join(root, "Synced", "MacOnly.swift"), + "import ClerkKit\nfunc macOnly() { Clerk.configure(publishableKey: key) }\n", + ); +} + async function addProjectReference( ownerProjectPath: string, referencedProjectPath: string, @@ -739,6 +776,7 @@ describe("inspectIOSProject", () => { targetId: IOS_FIXTURE_IDS.appTarget, targetName: "MyApp", projectPath: "MyApp.xcodeproj", + platform: "ios", }); expect(inspection.workspaces).toEqual([ { path: "MyApp.xcworkspace", projectPaths: ["MyApp.xcodeproj"] }, @@ -1492,6 +1530,140 @@ let package = Package( expect(inspection.selection).toMatchObject({ state: "selected", targetName: "MyApp" }); }); + test("selects a pure macOS SwiftUI application as an Apple native target", async () => { + const root = await fixture({ complete: true, platform: "macos", includeKey: false }); + const inspection = await inspectIOSProject(root); + + expect(inspection.platform).toBe("macos"); + expect(inspection.selection).toMatchObject({ + state: "selected", + targetName: "MyApp", + platform: "macos", + }); + expect(inspection.appTargets).toHaveLength(1); + expect(inspection.appTargets[0]).toMatchObject({ + platform: "macos", + configurations: [ + { + deploymentTarget: { state: "resolved", value: "14.0" }, + }, + { + deploymentTarget: { state: "resolved", value: "14.0" }, + }, + ], + swift: { + status: "complete", + }, + }); + expect(inspection.diagnostics).not.toContainEqual( + expect.objectContaining({ code: "xcode.no-ios-app-target" }), + ); + }); + + test("retains a macOS target but marks unresolved configuration platform evidence incomplete", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + releasePlatform: "unresolved", + }); + const inspection = await inspectIOSProject(root); + + expect(inspection.selection).toMatchObject({ + state: "selected", + targetName: "MyApp", + platform: "macos", + }); + expect(inspection.appTargets[0]).toMatchObject({ + platform: "macos", + platformEvidenceComplete: false, + }); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-target-platform", + severity: "error", + message: expect.stringContaining("Debug=macOS, Release=unresolved"), + }), + ); + }); + + test("names the visionOS boundary while preserving read-only inspection", async () => { + const root = await fixture({ complete: true }); + await convertIOSFixtureToMultiplatform(root); + await addVisionOSDestinationsToFixture(root); + + const inspection = await inspectIOSProject(root); + + expect(inspection.selection).toMatchObject({ state: "selected", targetName: "MyApp" }); + expect(inspection.appTargets[0]?.platformEvidenceComplete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-target-platform", + message: "MyApp also ships visionOS, which Clerk CLI can inspect but does not automate.", + remedy: expect.stringContaining("Read-only inspection completed"), + }), + ); + expect(inspection.diagnostics.map((diagnostic) => diagnostic.message).join("\n")).not.toContain( + "xros", + ); + }); + + test("keeps concrete cross-configuration platform conflicts discoverable but unsafe", async () => { + const root = await fixture({ + complete: true, + platform: "macos", + releasePlatform: "ios", + }); + const inspection = await inspectIOSProject(root); + + expect(inspection.selection.state).toBe("selected"); + expect(inspection.appTargets[0]?.platformEvidenceComplete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.unresolved-target-platform", + message: expect.stringContaining("Debug=macOS, Release=iOS"), + }), + ); + }); + + test("ignores iOS-only Clerk UI evidence for a macOS target", async () => { + const root = await fixture({ complete: true, platform: "macos", includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `#if os(iOS) +import ClerkKitUI +let authentication = AuthView() +#elseif os(macOS) +import ClerkKit +func configureClerk() { Clerk.configure(publishableKey: key) } +#endif +`, + ); + + const inspection = await inspectIOSProject(root); + const swift = inspection.appTargets[0]?.swift; + + expect(inspection.platform).toBe("macos"); + expect(swift?.importsClerkKitUI).toEqual([]); + expect(swift?.authViewReferences).toEqual([]); + expect(swift?.authFlowReferences).toEqual([]); + expect(swift?.importsClerkKit).toEqual([{ path: "MyApp/MyAppApp.swift" }]); + expect(swift?.configureCalls).toHaveLength(1); + }); + + test("requires target selection when a root contains separate iOS and macOS apps", async () => { + const root = await fixture({ platform: "macos", secondTarget: true }); + const ambiguous = await inspectIOSProject(root); + const macOS = await inspectIOSProject(root, { target: "MyApp" }); + const iOS = await inspectIOSProject(root, { target: "AdminApp" }); + + expect(ambiguous.platform).toBe("apple-native"); + expect(ambiguous.selection.state).toBe("ambiguous"); + expect(macOS.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect(macOS.platform).toBe("macos"); + expect(iOS.selection).toMatchObject({ state: "selected", platform: "ios" }); + expect(iOS.platform).toBe("ios"); + }); + test("preserves conflicting configuration values instead of guessing", async () => { const root = await fixture({ conflictingBundle: true }); const inspection = await inspectIOSProject(root); @@ -1709,6 +1881,87 @@ struct MyApp: App { expect(swift?.configureCalls).toEqual([]); }); + test("honors macOS platform filters for a selected synchronized target", async () => { + const root = await fixture({ complete: false, platform: "macos", includeKey: false }); + await addSynchronizedPlatformFilteredSources(root); + + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + const synchronizedAuthViews = target?.swift.authViewReferences + .map((reference) => reference.path) + .filter((path) => path.startsWith("Synced/")); + const synchronizedConfigureCalls = target?.swift.configureCalls + .map((call) => call.path) + .filter((path) => path.startsWith("Synced/")); + + expect(target?.platform).toBe("macos"); + expect(synchronizedAuthViews).toEqual([]); + expect(synchronizedConfigureCalls).toEqual(["Synced/MacOnly.swift"]); + }); + + test("includes every recognized synchronized platform filter in ownership discovery", async () => { + const root = await fixture({ complete: false, platform: "macos", includeKey: false }); + await addSynchronizedPlatformFilteredSources(root); + + const memberships = await inspectIOSSourceMembership(root); + const target = memberships.find( + (membership) => membership.targetId === IOS_FIXTURE_IDS.appTarget, + ); + const synchronizedSources = target?.files + .map((file) => file.relativePath) + .filter((path) => path.startsWith("Synced/")); + + expect(synchronizedSources).toEqual(["Synced/IOSOnly.swift", "Synced/MacOnly.swift"]); + }); + + test("keeps unknown synchronized filters visible while marking evidence incomplete", async () => { + const root = await fixture({ complete: false, includeKey: false }); + const synchronizedRootId = "404040404040404040404040"; + const exceptionId = "414141414141414141414141"; + await transformProject(root, (objects) => { + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + exceptions: [exceptionId], + path: "Synced", + sourceTree: "", + }; + objects[exceptionId] = { + isa: "PBXFileSystemSynchronizedBuildFileExceptionSet", + platformFiltersByRelativePath: { + "FutureOnly.swift": ["futureos"], + "Malformed.swift": "macos", + }, + target: IOS_FIXTURE_IDS.appTarget, + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + }); + await mkdir(join(root, "Synced"), { recursive: true }); + await Bun.write( + join(root, "Synced", "FutureOnly.swift"), + "import ClerkKitUI\nstruct FutureOnly { let view = AuthView() }\n", + ); + await Bun.write( + join(root, "Synced", "Malformed.swift"), + "import ClerkKit\nfunc malformed() { Clerk.configure(publishableKey: key) }\n", + ); + + const inspection = await inspectIOSProject(root); + const memberships = await inspectIOSSourceMembership(root); + const membership = memberships.find( + (candidate) => candidate.targetId === IOS_FIXTURE_IDS.appTarget, + ); + const synchronizedSources = membership?.files + .map((file) => file.relativePath) + .filter((path) => path.startsWith("Synced/")); + + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(false); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-source-membership" }), + ); + expect(membership?.complete).toBe(false); + expect(synchronizedSources).toEqual(["Synced/FutureOnly.swift", "Synced/Malformed.swift"]); + }); + test("does not use Catalyst-only classic build-file membership as native iOS evidence", async () => { const root = await fixture({ complete: true }); const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 98e791336..a49ce9d5b 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -39,6 +39,7 @@ import type { IOSClerkPackageState, IOSDiagnostic, IOSEntitlementsInspection, + IOSNativePlatform, IOSPackageReference, IOSProductLinkState, IOSProjectInspection, @@ -67,7 +68,12 @@ const SOURCE_IGNORES = new Set([ interface ParsedProject { inspection: IOSProjectInspection; appTargets: IOSAppTarget[]; - appTargetCandidates: Array<{ targetId: string; targetName: string; projectPath: string }>; + appTargetCandidates: Array<{ + targetId: string; + targetName: string; + projectPath: string; + platform: IOSNativePlatform; + }>; diagnostics: IOSDiagnostic[]; sourceMemberships?: IOSTargetSourceMembership[]; } @@ -119,7 +125,10 @@ function canonicalRequirement(value: unknown): Record | undefine return Object.keys(requirement).length > 0 ? requirement : undefined; } -function buildFileIOSApplicability(object: PbxObject): { +function buildFilePlatformApplicability( + object: PbxObject, + platform?: IOSNativePlatform, +): { applies: boolean; recognized: boolean; } { @@ -137,13 +146,34 @@ function buildFileIOSApplicability(object: PbxObject): { } const filters = [...asStringArray(rawFilters), ...(platformFilter ? [platformFilter] : [])]; if (filters.length === 0) return { applies: true, recognized: true }; - if (filters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter))) { - return { applies: true, recognized: true }; - } const recognized = filters.every((filter) => - /(?:maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)/i.test(filter), + /^(?:ios|iphone(?:os|simulator)?|maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)$/i.test( + filter, + ), + ); + if (!recognized) return { applies: false, recognized: false }; + if (!platform) return { applies: true, recognized: true }; + const applies = + platform === "ios" + ? filters.some((filter) => /^(?:ios|iphone(?:os|simulator)?)$/i.test(filter)) + : filters.some((filter) => /^macos$/i.test(filter)); + return { applies, recognized: true }; +} + +function describeUnmodeledApplePlatforms(platforms: string[]): string { + const hasVisionOS = platforms.some((platform) => + /^(?:visionos|xros|xrsimulator)$/i.test(platform), + ); + const hasMacCatalyst = platforms.some((platform) => /^maccatalyst$/i.test(platform)); + const remaining = platforms.filter( + (platform) => + !/^(?:visionos|xros|xrsimulator)$/i.test(platform) && !/^maccatalyst$/i.test(platform), ); - return { applies: false, recognized }; + return [ + ...(hasVisionOS ? ["visionOS"] : []), + ...(hasMacCatalyst ? ["Mac Catalyst"] : []), + ...remaining, + ].join(", "); } function inspectInlinePublishableKey( @@ -245,6 +275,7 @@ function targetProductState( targetObject: PbxObject, objects: PbxObjects, productName: "ClerkKit" | "ClerkKitUI", + platform: IOSNativePlatform, ): { state: IOSProductLinkState; productIds: string[]; packageIds: string[] } { const targetProductIds = asStringArray(targetObject.packageProductDependencies); const matchingProductIds = targetProductIds.filter((id) => { @@ -263,7 +294,7 @@ function targetProductState( if (phase?.isa !== "PBXFrameworksBuildPhase") continue; for (const buildFileId of asStringArray(phase.files)) { const buildFile = objects[buildFileId]; - if (!buildFile || !buildFileIOSApplicability(buildFile).applies) continue; + if (!buildFile || !buildFilePlatformApplicability(buildFile, platform).applies) continue; const productRef = asString(buildFile.productRef); if (productRef) linkedProductIds.add(productRef); } @@ -287,9 +318,10 @@ function inspectTargetPackages( objects: PbxObjects, packages: IOSPackageReference[], diagnostics: IOSDiagnostic[], + platform: IOSNativePlatform, ): IOSClerkPackageState { - const clerkKit = targetProductState(targetObject, objects, "ClerkKit"); - const clerkKitUI = targetProductState(targetObject, objects, "ClerkKitUI"); + const clerkKit = targetProductState(targetObject, objects, "ClerkKit", platform); + const clerkKitUI = targetProductState(targetObject, objects, "ClerkKitUI", platform); const packageById = new Map(packages.map((item) => [item.objectId, item])); const productIds = [...clerkKit.productIds, ...clerkKitUI.productIds]; const productPackageIds = [...clerkKit.packageIds, ...clerkKitUI.packageIds]; @@ -345,6 +377,7 @@ function appleEntitlementState( async function inspectEntitlements( root: string, absolutePath: string, + platform: IOSNativePlatform, evidence: IOSSourceEvidence[], diagnostics: IOSDiagnostic[], ): Promise { @@ -390,7 +423,9 @@ async function inspectEntitlements( rawAssociatedDomains.every((value): value is string => typeof value === "string") ? rawAssociatedDomains : []; - const applicationIdentifier = asString(parsed["application-identifier"]); + const applicationIdentifier = asString( + parsed[platform === "macos" ? "com.apple.application-identifier" : "application-identifier"], + ); const signInWithAppleState = appleEntitlementState(parsed); if (signInWithAppleState === "invalid") { diagnostics.push({ @@ -425,6 +460,7 @@ async function inspectEntitlements( async function attachEntitlements( root: string, projectPath: string, + platform: IOSNativePlatform, configurations: IOSBuildConfiguration[], contextsByConfiguration: Map, diagnostics: IOSDiagnostic[], @@ -461,6 +497,7 @@ async function attachEntitlements( await inspectEntitlements( root, absolutePath, + platform, configuration.entitlementsPath.evidence, diagnostics, ), @@ -593,6 +630,7 @@ function synchronizedExclusions( relevantPhaseIds: Set, objects: PbxObjects, state: { complete: boolean }, + platform?: IOSNativePlatform, ): Set { const excluded = new Set(); for (const exceptionId of synchronizedStringCollection(group, "exceptions", state)) { @@ -639,10 +677,10 @@ function synchronizedExclusions( state.complete = false; continue; } - if ( - platformFilters.length > 0 && - !platformFilters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter)) - ) { + const applicability = buildFilePlatformApplicability({ platformFilters }, platform); + if (!applicability.recognized) { + state.complete = false; + } else if (!applicability.applies) { excluded.add(normalizeSynchronizedPath(path)); } } @@ -714,6 +752,7 @@ async function sourceFilesForTarget(options: { objects: PbxObjects; parents: PbxParentIndex; diagnostics: IOSDiagnostic[]; + platform?: IOSNativePlatform; }): Promise<{ files: Array<{ absolutePath: string; relativePath: string }>; complete: boolean; @@ -727,6 +766,7 @@ async function sourceFilesForTarget(options: { objects, parents, diagnostics, + platform, } = options; const projectDirectory = dirname(projectPath); const files = new Map(); @@ -773,7 +813,7 @@ async function sourceFilesForTarget(options: { ); continue; } - const applicability = buildFileIOSApplicability(buildFile); + const applicability = buildFilePlatformApplicability(buildFile, platform); if (!applicability.applies) { if (!applicability.recognized) state.complete = false; continue; @@ -855,7 +895,14 @@ async function sourceFilesForTarget(options: { continue; } - const excluded = synchronizedExclusions(group, targetId, sourcePhaseIds, objects, state); + const excluded = synchronizedExclusions( + group, + targetId, + sourcePhaseIds, + objects, + state, + platform, + ); await collectSwiftFiles(root, groupPath, groupPath, excluded, files, state); } @@ -886,6 +933,7 @@ async function parseProject( root: string, projectPath: string, requestedTarget?: string, + requestedPlatform?: IOSNativePlatform, ): Promise { const projectRelativePath = relativeIOSPath(root, projectPath); const pbxprojPath = resolve(projectPath, "project.pbxproj"); @@ -1050,17 +1098,91 @@ async function parseProject( objects, parents, diagnostics: configurationDiagnostics, + platform: requestedPlatform, }); - if ( + const concretePlatforms = new Set( + targetConfigurations.flatMap((configuration) => + configuration.platformEvidenceComplete && configuration.platform + ? [configuration.platform] + : [], + ), + ); + const hasUncertainConfiguration = targetConfigurations.some( + (configuration) => !configuration.platformEvidenceComplete, + ); + const hasResolvedUnsupportedConfiguration = targetConfigurations.some( + (configuration) => configuration.platformEvidenceComplete && !configuration.platform, + ); + const inferredPlatforms = new Set( + targetConfigurations.flatMap((configuration) => + configuration.platform ? [configuration.platform] : [], + ), + ); + const targetPlatform: IOSNativePlatform | undefined = requestedPlatform + ? requestedPlatform + : concretePlatforms.has("ios") + ? "ios" + : concretePlatforms.has("macos") + ? "macos" + : hasUncertainConfiguration || targetConfigurations.length === 0 + ? inferredPlatforms.has("macos") + ? "macos" + : "ios" + : undefined; + if (!targetPlatform) continue; + const platformEvidenceComplete = targetConfigurations.length > 0 && - !targetConfigurations.some((configuration) => configuration.isIOS) - ) { - continue; + !hasUncertainConfiguration && + !hasResolvedUnsupportedConfiguration && + concretePlatforms.size === 1; + if (!platformEvidenceComplete) { + const unmodeledPlatforms = [ + ...new Set( + targetConfigurations.flatMap((configuration) => configuration.unmodeledPlatforms), + ), + ].sort(); + const configurationSummary = + targetConfigurations.length === 0 + ? "no build configurations were inspectable" + : targetConfigurations + .map((configuration) => { + const label = configuration.platform + ? configuration.platform === "macos" + ? "macOS" + : "iOS" + : "unsupported"; + return `${configuration.model.name}=${ + configuration.platformEvidenceComplete ? label : "unresolved" + }`; + }) + .join(", "); + configurationDiagnostics.push({ + code: "xcode.unresolved-target-platform", + severity: "error", + message: + unmodeledPlatforms.length > 0 + ? `${targetName} also ships ${describeUnmodeledApplePlatforms( + unmodeledPlatforms, + )}, which Clerk CLI can inspect but does not automate.` + : `${targetName} does not have one proven native platform across every build configuration (${configurationSummary}).`, + remedy: + unmodeledPlatforms.length > 0 + ? "Read-only inspection completed. Automatic setup currently supports only non-Catalyst iOS and native macOS destinations; use a target limited to those destinations or configure Clerk manually for this target." + : "Resolve SDKROOT, SUPPORTED_PLATFORMS, and SUPPORTS_MACCATALYST consistently for every build configuration before running Clerk setup.", + evidence: [ + { + path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + objectId: targetId, + keyPath: "buildConfigurations", + }, + ], + }); } appTargetCandidates.push({ targetId, targetName, projectPath: projectRelativePath, + platform: targetPlatform, }); if (requestedTarget && requestedTarget !== targetId && requestedTarget !== targetName) { continue; @@ -1068,9 +1190,15 @@ async function parseProject( diagnostics.push(...configurationDiagnostics); const configurations = targetConfigurations.map((configuration) => configuration.model); + const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => + targetConfigurations.some((configuration) => + configuration.supportedPlatforms.includes(platform), + ), + ); await attachEntitlements( root, projectPath, + targetPlatform, configurations, new Map( targetConfigurations.map((configuration) => [ @@ -1081,18 +1209,27 @@ async function parseProject( diagnostics, ); addBuildSettingConflictDiagnostics(targetName, configurations, diagnostics); - const targetSources = sourceMembershipById.get(targetId) ?? { - files: [], - complete: false, - diagnostics: [], - }; - diagnostics.push(...targetSources.diagnostics); + const ownershipSources = sourceMembershipById.get(targetId); + const targetSourceDiagnostics: IOSDiagnostic[] = []; + const targetSources = await sourceFilesForTarget({ + root, + projectPath, + groupRootDirectory, + targetId, + targetObject, + objects, + parents, + diagnostics: targetSourceDiagnostics, + platform: targetPlatform, + }); + targetSources.complete &&= ownershipSources?.complete ?? false; + diagnostics.push(...targetSourceDiagnostics); const swiftInspection = targetSources.files.length > 0 ? await inspectSwiftSources(targetSources.files, { membershipComplete: targetSources.complete, - platform: "ios", + platform: targetPlatform, }) : emptySwiftInspection(); if (targetSources.complete && !swiftInspection.evidenceComplete) { @@ -1112,6 +1249,9 @@ async function parseProject( const appTarget: IOSAppTarget = { id: targetId, name: targetName, + platform: targetPlatform, + supportedPlatforms, + platformEvidenceComplete, productName: asString(targetObject.productName), projectPath: projectRelativePath, configurations, @@ -1123,6 +1263,7 @@ async function parseProject( objects, packages, diagnostics, + targetPlatform, ), swift: swiftInspection, }; @@ -1185,7 +1326,7 @@ function selectTarget( diagnostics.push({ code: "xcode.target-not-found", severity: "error", - message: `No iOS application target matches "${requestedTarget}".`, + message: `No supported iOS or macOS application target matches "${requestedTarget}".`, remedy: "Choose one of the reported target names or IDs.", evidence: candidates.map((candidate) => ({ path: candidate.projectPath, @@ -1207,8 +1348,9 @@ function selectTarget( diagnostics.push({ code: "xcode.no-ios-app-target", severity: "error", - message: "No iOS application target was found.", - remedy: "Run from an iOS app project, or pass --framework ios from its project root.", + message: "No supported iOS or macOS application target was found.", + remedy: + "Run from an iOS or macOS app project, or pass --framework ios from its project root.", evidence: [], }); return { state: "none" }; @@ -1217,7 +1359,7 @@ function selectTarget( diagnostics.push({ code: "xcode.ambiguous-app-target", severity: "error", - message: `Found ${candidates.length} iOS application targets; none was selected automatically.`, + message: `Found ${candidates.length} supported Apple application targets; none was selected automatically.`, remedy: "Rerun with --target .", evidence: candidates.map((candidate) => ({ path: candidate.projectPath, @@ -1251,7 +1393,12 @@ async function detectGeneratedProject( export async function inspectIOSProject( rootInput: string, - options: { target?: string; exhaustiveContainerDiscovery?: boolean } = {}, + options: { + target?: string; + exhaustiveContainerDiscovery?: boolean; + /** Inspect one platform's conditioned target settings without changing its primary platform. */ + platform?: IOSNativePlatform; + } = {}, ): Promise { const invocationPath = resolve(rootInput); const root = invocationPath.endsWith(".xcodeproj") @@ -1264,7 +1411,7 @@ export async function inspectIOSProject( if (await hasInterruptedIOSFileTransaction(root)) { return { schemaVersion: 1, - platform: "ios", + platform: "apple-native", root, workspaces: [], projects: [], @@ -1277,7 +1424,7 @@ export async function inspectIOSProject( code: "xcode.interrupted-file-transaction", severity: "error", message: - "Clerk stopped inspection because an iOS file update is incomplete or still active.", + "Clerk stopped inspection because an Apple project file update is incomplete or still active.", remedy: "Wait for any running Clerk command to finish. If none is running, run `clerk init` without `--dry-run` to recover the interrupted update before inspecting the project again.", evidence: [], @@ -1321,7 +1468,7 @@ export async function inspectIOSProject( code: "xcode.no-project", severity: "error", message: "No .xcodeproj was found in the inspected root.", - remedy: "Run this command from the directory containing your iOS project.", + remedy: "Run this command from the directory containing your iOS or macOS project.", evidence: [], }); } @@ -1331,7 +1478,7 @@ export async function inspectIOSProject( const appTargetCandidates: ParsedProject["appTargetCandidates"] = []; const sourceMemberships: IOSTargetSourceMembership[] = []; for (const projectPath of [...projectPaths].sort()) { - const parsed = await parseProject(root, projectPath, options.target); + const parsed = await parseProject(root, projectPath, options.target, options.platform); projects.push(parsed.inspection); appTargets.push(...parsed.appTargets); appTargetCandidates.push(...parsed.appTargetCandidates); @@ -1384,9 +1531,14 @@ export async function inspectIOSProject( ) : undefined; const localPublishableKeyInspection = inspectInlinePublishableKey(selectedAppTarget, diagnostics); + const candidatePlatforms = new Set(appTargetCandidates.map((candidate) => candidate.platform)); + const inspectionPlatform = + selectedAppTarget?.platform ?? + (candidatePlatforms.size === 1 ? appTargetCandidates[0]?.platform : undefined) ?? + "apple-native"; const result: IOSProjectInspectionResult = { schemaVersion: 1, - platform: "ios", + platform: inspectionPlatform, root, workspaces: workspaces.sort((a, b) => a.path.localeCompare(b.path)), projects: projects.sort((a, b) => a.path.localeCompare(b.path)), diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index f9d832e4f..7ceff9450 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -138,6 +138,35 @@ async function transformProject( await Bun.write(path, buildPbxProject(graph.project)); } +async function makeMultiplatformTarget(root: string): Promise { + await transformProject(root, (graph) => { + for (const configurationId of [IOS_FIXTURE_IDS.projectDebug, IOS_FIXTURE_IDS.projectRelease]) { + const settings = graph.objects[configurationId]!.buildSettings as Record; + settings.SDKROOT = "auto"; + } + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = graph.objects[configurationId]!.buildSettings as Record; + settings.SDKROOT = "auto"; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + settings.IPHONEOS_DEPLOYMENT_TARGET = "17.0"; + settings.MACOSX_DEPLOYMENT_TARGET = "14.0"; + } + }); +} + +async function setDeploymentTargets( + root: string, + targets: { ios?: string; macos?: string }, +): Promise { + await transformProject(root, (graph) => { + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = graph.objects[configurationId]!.buildSettings as Record; + if (targets.ios) settings.IPHONEOS_DEPLOYMENT_TARGET = targets.ios; + if (targets.macos) settings.MACOSX_DEPLOYMENT_TARGET = targets.macos; + } + }); +} + function removeClerkSDK(graph: MutableGraph): void { graph.root.packageReferences = []; removeClerkProductLinks(graph); @@ -219,6 +248,122 @@ afterEach(async () => { }); describe("iOS Clerk SDK installer", () => { + test("blocks package planning when any configuration platform is unresolved", async () => { + const root = await fixture({ platform: "macos", releasePlatform: "unresolved" }); + await transformProject(root, removeClerkSDK); + + const plan = await planIOSSDKInstall({ ...installOptions(root), platform: "macos" }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "unresolved-platform" }], + }); + }); + + test("blocks package changes when a multiplatform target includes visionOS", async () => { + const root = await fixture({ clerkSDK: false }); + await makeMultiplatformTarget(root); + await transformProject(root, (graph) => { + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = graph.objects[configurationId]!.buildSettings as Record; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + } + }); + const before = await readFile(pbxprojPath(root)); + + const plan = await planIOSSDKInstall(installOptions(root)); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "unresolved-platform" }], + }); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "blocked" }); + expect(await readFile(pbxprojPath(root))).toEqual(before); + }); + + test("blocks ClerkKit for an iOS target below the supported deployment floor", async () => { + const root = await fixture({ clerkSDK: false }); + await setDeploymentTargets(root, { ios: "16.4" }); + const before = await readFile(pbxprojPath(root)); + + const plan = await planIOSSDKInstall(installOptions(root)); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [ + { + code: "incompatible-sdk", + message: expect.stringContaining("requires iOS 17.0 or newer"), + }, + ], + }); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "blocked" }); + expect(await readFile(pbxprojPath(root))).toEqual(before); + }); + + test("blocks ClerkKit for a macOS target below the supported deployment floor", async () => { + const root = await fixture({ clerkSDK: "core-only", platform: "macos" }); + await setDeploymentTargets(root, { macos: "13.5" }); + + const plan = await planIOSSDKInstall({ ...installOptions(root), platform: "macos" }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [ + { + code: "incompatible-sdk", + message: expect.stringContaining("requires macOS 14.0 or newer"), + }, + ], + }); + }); + + test("blocks a shared ClerkKit link when any supported platform is below its floor", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + await makeMultiplatformTarget(root); + await setDeploymentTargets(root, { ios: "17.0", macos: "13.5" }); + + const plan = await planIOSSDKInstall(installOptions(root)); + + expect(plan).toMatchObject({ + status: "blocked", + platform: "ios", + supportedPlatforms: ["ios", "macos"], + blockers: [ + { + code: "incompatible-sdk", + message: expect.stringContaining("requires macOS 14.0 or newer"), + }, + ], + }); + }); + + test("installs ClerkKit and ClerkKitUI for a pure macOS app", async () => { + const root = await fixture({ platform: "macos" }); + await transformProject(root, removeClerkSDK); + + const plan = await planIOSSDKInstall({ + ...installOptions(root, true), + platform: "macos", + requirePrebuiltAuthCompatibility: true, + }); + + expect(plan).toMatchObject({ + status: "ready", + platform: "macos", + products: ["ClerkKit", "ClerkKitUI"], + blockers: [], + }); + expect((await applyIOSSDKInstall(plan)).status).toBe("applied"); + const inspection = await inspectIOSProject(root, { target: IOS_FIXTURE_IDS.appTarget }); + expect(inspection.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + test("returns satisfied without serializing or changing a configured project", async () => { const root = await fixture(); const before = await readFile(pbxprojPath(root)); @@ -553,16 +698,48 @@ describe("iOS Clerk SDK installer", () => { test("adds an iOS-only ClerkKit link when a multiplatform target already links it on macOS", async () => { const root = await fixture({ clerkSDK: "core-only" }); + await makeMultiplatformTarget(root); await transformProject(root, (graph) => { - for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { - const settings = graph.objects[configurationId]!.buildSettings as Record; - settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; - } graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "macos"; }); const plan = await planIOSSDKInstall(installOptions(root)); - expect(plan.status).toBe("ready"); + expect(plan).toMatchObject({ + status: "ready", + platform: "ios", + supportedPlatforms: ["ios", "macos"], + }); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "applied" }); + + const graph = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + const links = (graph.frameworks.files as string[]) + .map((id) => graph.objects[id]!) + .filter((object) => object.productRef === IOS_FIXTURE_IDS.clerkKit); + expect(links).toHaveLength(2); + expect( + links + .map((object) => object.platformFilter) + .sort((a, b) => String(a).localeCompare(String(b))), + ).toEqual(["ios", "macos"]); + const afterApply = await readFile(pbxprojPath(root)); + const rerun = await planIOSSDKInstall(installOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSSDKInstall(rerun)).status).toBe("satisfied"); + expect(await readFile(pbxprojPath(root))).toEqual(afterApply); + }); + + test("adds a macOS-only ClerkKit link when a multiplatform target already links it on iOS", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + await makeMultiplatformTarget(root); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "ios"; + }); + + const plan = await planIOSSDKInstall({ + ...installOptions(root), + supportedPlatforms: ["ios", "macos"], + }); + expect(plan).toMatchObject({ status: "ready", supportedPlatforms: ["ios", "macos"] }); expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "applied" }); const graph = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); @@ -575,6 +752,27 @@ describe("iOS Clerk SDK installer", () => { .map((object) => object.platformFilter) .sort((a, b) => String(a).localeCompare(String(b))), ).toEqual(["ios", "macos"]); + }); + + test("uses one unfiltered link when a multiplatform target has no ClerkKit build file", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + await makeMultiplatformTarget(root); + await transformProject(root, (graph) => { + graph.frameworks.files = []; + delete graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]; + }); + + const plan = await planIOSSDKInstall(installOptions(root)); + expect(plan).toMatchObject({ status: "ready", supportedPlatforms: ["ios", "macos"] }); + expect((await applyIOSSDKInstall(plan)).status).toBe("applied"); + + const graph = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + const links = (graph.frameworks.files as string[]) + .map((id) => graph.objects[id]!) + .filter((object) => object.productRef === IOS_FIXTURE_IDS.clerkKit); + expect(links).toHaveLength(1); + expect(links[0]?.platformFilter).toBeUndefined(); + expect(links[0]?.platformFilters).toBeUndefined(); expect((await planIOSSDKInstall(installOptions(root))).status).toBe("satisfied"); }); @@ -598,6 +796,59 @@ describe("iOS Clerk SDK installer", () => { }, ); + test("composes split ClerkKit and ClerkKitUI platform filters in one candidate project", async () => { + const root = await fixture(); + await makeMultiplatformTarget(root); + await transformProject(root, (graph) => { + graph.objects[IOS_FIXTURE_IDS.clerkKitBuildFile]!.platformFilter = "ios"; + graph.objects[IOS_FIXTURE_IDS.clerkKitUIBuildFile]!.platformFilter = "macos"; + }); + + const plan = await planIOSSDKInstall(installOptions(root, true)); + expect(plan).toMatchObject({ + status: "ready", + products: ["ClerkKit", "ClerkKitUI"], + supportedPlatforms: ["ios", "macos"], + }); + expect(plan.actions).toEqual([ + "Link ClerkKit for macOS in the selected target's Frameworks phase.", + "Link ClerkKitUI for iOS in the selected target's Frameworks phase.", + ]); + expect((await applyIOSSDKInstall(plan)).status).toBe("applied"); + + const graph = mutableGraph(parsePbxProject(await readFile(pbxprojPath(root), "utf8"))); + for (const [productId, expectedFilters] of [ + [IOS_FIXTURE_IDS.clerkKit, ["ios", "macos"]], + [IOS_FIXTURE_IDS.clerkKitUI, ["ios", "macos"]], + ] as const) { + expect( + (graph.frameworks.files as string[]) + .map((id) => graph.objects[id]!) + .filter((object) => object.productRef === productId) + .map((object) => object.platformFilter) + .sort((a, b) => String(a).localeCompare(String(b))), + ).toEqual([...expectedFilters]); + } + expect((await planIOSSDKInstall(installOptions(root, true))).status).toBe("satisfied"); + }); + + test("honors an explicit macOS inspection view for a multiplatform target", async () => { + const root = await fixture(); + await makeMultiplatformTarget(root); + + const plan = await planIOSSDKInstall({ + ...installOptions(root, true), + platform: "macos", + supportedPlatforms: ["ios", "macos"], + }); + + expect(plan).toMatchObject({ + status: "satisfied", + platform: "macos", + supportedPlatforms: ["ios", "macos"], + }); + }); + test("reuses a verified local package and canonical remote URL variants", async () => { const localRoot = await fixture(); await mkdir(join(localRoot, "LocalClerk", "Sources", "ClerkKit"), { recursive: true }); 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 3a2031332..b4d7e627c 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -21,12 +21,17 @@ import { type PbxObject, type PbxObjects, } from "./pbx.ts"; +import type { IOSNativePlatform } from "./types.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const CLERK_REPOSITORY = "https://github.com/clerk/clerk-ios"; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_PACKAGE_METADATA_BYTES = 2_000_000; const PRODUCT_NAMES = ["ClerkKit", "ClerkKitUI"] as const; +const CLERK_SDK_MINIMUM_DEPLOYMENT_TARGET = { + ios: "17.0", + macos: "14.0", +} as const satisfies Record; export const DEFAULT_CLERK_IOS_MINIMUM_VERSION = "1.0.0"; // These floors are equal today, but remain separate so AuthView can raise its @@ -40,6 +45,10 @@ export interface IOSSDKInstallOptions { /** Project-root-relative path selected by the iOS inspector. */ projectPath: string; targetId: string; + /** Primary platform view used to inspect the selected target. */ + platform?: IOSNativePlatform; + /** Every platform on which the selected target must link the requested products. */ + supportedPlatforms?: IOSNativePlatform[]; includeClerkKitUI?: boolean; /** Used only when a new clerk-ios remote reference must be created. */ minimumVersion?: string; @@ -56,6 +65,7 @@ export type IOSSDKInstallBlockerCode = | "target-not-found" | "ambiguous-target" | "incomplete-container-discovery" + | "unresolved-platform" | "ambiguous-package" | "duplicate-package" | "unattributed-product" @@ -78,6 +88,8 @@ export interface IOSSDKInstallPlan { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; + supportedPlatforms: IOSNativePlatform[]; products: IOSSDKProduct[]; minimumVersion: string; requirePrebuiltAuthCompatibility?: true; @@ -109,8 +121,9 @@ interface VerifiedPackage { interface ProductGraph { productId?: string; inTarget: boolean; - buildFileId?: string; - hasNonIOSBuildFile: boolean; + /** One applicable build file per requested platform, when present. */ + buildFileIds: Partial>; + hasAnyBuildFile: boolean; } interface PreparedInstall { @@ -128,6 +141,41 @@ function requestedProducts(includeClerkKitUI: boolean | undefined): IOSSDKProduc return includeClerkKitUI ? ["ClerkKit", "ClerkKitUI"] : ["ClerkKit"]; } +function canonicalPlatforms(platforms: readonly IOSNativePlatform[]): IOSNativePlatform[] { + const selected = new Set(platforms); + return (["ios", "macos"] as const).filter((platform) => selected.has(platform)); +} + +function planPlatforms(options: IOSSDKInstallOptions): IOSNativePlatform[] { + return canonicalPlatforms(options.supportedPlatforms ?? [options.platform ?? "ios"]); +} + +function parsedDeploymentTarget(value: string): [number, number, number] | undefined { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim()); + if (!match) return undefined; + const components = match.slice(1).map((component) => Number(component ?? "0")) as [ + number, + number, + number, + ]; + return components.every(Number.isSafeInteger) ? components : undefined; +} + +function deploymentTargetMeetsClerkSDKFloor(value: string, platform: IOSNativePlatform): boolean { + const actual = parsedDeploymentTarget(value); + const minimum = parsedDeploymentTarget(CLERK_SDK_MINIMUM_DEPLOYMENT_TARGET[platform]); + if (!actual || !minimum) return false; + for (let index = 0; index < actual.length; index += 1) { + if (actual[index]! > minimum[index]!) return true; + if (actual[index]! < minimum[index]!) return false; + } + return true; +} + +function platformLabel(platform: IOSNativePlatform): string { + return platform === "macos" ? "macOS" : "iOS"; +} + function validMinimumVersion(value: string): boolean { return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value); } @@ -220,6 +268,8 @@ function makePlan( root, projectPath, targetId: options.targetId, + platform: options.platform ?? "ios", + supportedPlatforms: planPlatforms(options), products: requestedProducts(options.includeClerkKitUI), minimumVersion: effectiveMinimumVersion(options), ...(options.requirePrebuiltAuthCompatibility ? { requirePrebuiltAuthCompatibility: true } : {}), @@ -540,7 +590,10 @@ function clerkProductName(object: PbxObject | undefined): IOSSDKProduct | undefi return PRODUCT_NAMES.find((productName) => productName === name); } -function buildFileIOSApplicability(object: PbxObject): { +function buildFilePlatformApplicability( + object: PbxObject, + platform: IOSNativePlatform, +): { applies: boolean; recognized: boolean; } { @@ -558,13 +611,17 @@ function buildFileIOSApplicability(object: PbxObject): { } const filters = [...asStringArray(rawFilters), ...(platformFilter ? [platformFilter] : [])]; if (filters.length === 0) return { applies: true, recognized: true }; - if (filters.some((filter) => /(?:^|[^a-z])(?:ios|iphone)/i.test(filter))) { - return { applies: true, recognized: true }; - } const recognized = filters.every((filter) => - /(?:maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)/i.test(filter), + /^(?:ios|iphone(?:os|simulator)?|maccatalyst|macos|tvos|watchos|xros|visionos|driverkit)$/i.test( + filter, + ), ); - return { applies: false, recognized }; + if (!recognized) return { applies: false, recognized: false }; + const applies = + platform === "ios" + ? filters.some((filter) => /^(?:ios|iphone(?:os|simulator)?)$/i.test(filter)) + : filters.some((filter) => /^macos$/i.test(filter)); + return { applies, recognized: true }; } function validateProductPackage( @@ -610,6 +667,7 @@ function scanProductGraph( objects: PbxObjects, verifiedPackageIds: Set, unsafeLocalPackageIds: Set, + platforms: IOSNativePlatform[], ): { graph?: ProductGraph; blocker?: IOSSDKInstallBlocker } { const targetMatches = targetProductIds.filter( (id) => clerkProductName(objects[id]) === productName, @@ -623,8 +681,11 @@ function scanProductGraph( }; } - const phaseMatches: Array<{ buildFileId: string; productId: string }> = []; - let hasNonIOSBuildFile = false; + const phaseMatches: Array<{ + buildFileId: string; + productId: string; + applies: IOSNativePlatform[]; + }> = []; for (const buildFileId of frameworkFiles) { const buildFile = objects[buildFileId]; if (!buildFile || buildFile.isa !== "PBXBuildFile") { @@ -637,8 +698,11 @@ function scanProductGraph( } const productId = asString(buildFile.productRef); if (productId && clerkProductName(objects[productId]) === productName) { - const applicability = buildFileIOSApplicability(buildFile); - if (!applicability.recognized) { + const applicability = platforms.map((platform) => ({ + platform, + ...buildFilePlatformApplicability(buildFile, platform), + })); + if (applicability.some((item) => !item.recognized)) { return { blocker: { code: "unsupported-project", @@ -646,25 +710,38 @@ function scanProductGraph( }, }; } - if (applicability.applies) { - phaseMatches.push({ buildFileId, productId }); - } else { - hasNonIOSBuildFile = true; - } + phaseMatches.push({ + buildFileId, + productId, + applies: applicability.filter((item) => item.applies).map((item) => item.platform), + }); } } - if (phaseMatches.length > 1) { + for (const platform of platforms) { + if (phaseMatches.filter((match) => match.applies.includes(platform)).length > 1) { + return { + blocker: { + code: "duplicate-build-file", + message: `The selected target links ${productName} more than once for ${ + platform === "macos" ? "macOS" : "iOS" + } in its Frameworks phase.`, + }, + }; + } + } + + const targetProductId = targetMatches[0]; + const phaseProductIds = [...new Set(phaseMatches.map((match) => match.productId))]; + if (phaseProductIds.length > 1) { return { blocker: { - code: "duplicate-build-file", - message: `The selected target links ${productName} more than once in its Frameworks phase.`, + code: "duplicate-product", + message: `The selected target links more than one ${productName} product dependency.`, }, }; } - - const targetProductId = targetMatches[0]; - const phaseMatch = phaseMatches[0]; - if (targetProductId && phaseMatch && targetProductId !== phaseMatch.productId) { + const phaseProductId = phaseProductIds[0]; + if (targetProductId && phaseProductId && targetProductId !== phaseProductId) { return { blocker: { code: "duplicate-product", @@ -672,7 +749,7 @@ function scanProductGraph( }, }; } - const productId = targetProductId ?? phaseMatch?.productId; + const productId = targetProductId ?? phaseProductId; if (productId) { const blocker = validateProductPackage( productId, @@ -686,8 +763,13 @@ function scanProductGraph( graph: { productId, inTarget: targetProductId != null, - buildFileId: phaseMatch?.buildFileId, - hasNonIOSBuildFile, + buildFileIds: Object.fromEntries( + platforms.flatMap((platform) => { + const match = phaseMatches.find((candidate) => candidate.applies.includes(platform)); + return match ? [[platform, match.buildFileId]] : []; + }), + ), + hasAnyBuildFile: phaseMatches.length > 0, }, }; } @@ -696,6 +778,7 @@ function validateCandidateGraph( parts: ProjectParts, packageId: string, products: IOSSDKProduct[], + platforms: IOSNativePlatform[], ): boolean { const packageReferences = strictStringArray(parts.projectObject, "packageReferences"); const targetProducts = strictStringArray(parts.targetObject, "packageProductDependencies"); @@ -717,26 +800,31 @@ function validateCandidateGraph( const productId = productIds[0]; if (productIds.length !== 1 || !productId) return false; if (asString(parts.objects[productId]?.package) !== packageId) return false; - const linked = frameworkFiles.filter((buildFileId) => { - const buildFile = parts.objects[buildFileId]; - return ( - buildFile?.isa === "PBXBuildFile" && - asString(buildFile?.productRef) === productId && - buildFileIOSApplicability(buildFile).recognized && - buildFileIOSApplicability(buildFile).applies - ); - }); - if (linked.length !== 1) return false; - const allLinkedProducts = frameworkFiles.filter((buildFileId) => { - const buildFile = parts.objects[buildFileId]; - if (!buildFile || buildFile.isa !== "PBXBuildFile") return false; - const linkedProduct = parts.objects[asString(buildFile.productRef) ?? ""]; - return ( - clerkProductName(linkedProduct) === productName && - buildFileIOSApplicability(buildFile).applies - ); - }); - if (allLinkedProducts.length !== 1) return false; + for (const platform of platforms) { + const linked = frameworkFiles.filter((buildFileId) => { + const buildFile = parts.objects[buildFileId]; + if (!buildFile || buildFile.isa !== "PBXBuildFile") return false; + const applicability = buildFilePlatformApplicability(buildFile, platform); + return ( + asString(buildFile.productRef) === productId && + applicability.recognized && + applicability.applies + ); + }); + if (linked.length !== 1) return false; + const allLinkedProducts = frameworkFiles.filter((buildFileId) => { + const buildFile = parts.objects[buildFileId]; + if (!buildFile || buildFile.isa !== "PBXBuildFile") return false; + const linkedProduct = parts.objects[asString(buildFile.productRef) ?? ""]; + const applicability = buildFilePlatformApplicability(buildFile, platform); + return ( + clerkProductName(linkedProduct) === productName && + applicability.recognized && + applicability.applies + ); + }); + if (allLinkedProducts.length !== 1) return false; + } } return true; } @@ -859,6 +947,7 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise target.id === options.targetId && target.projectPath === projectPath, + ); + if (!inspectedTarget?.platformEvidenceComplete) { + return blocked( + options, + root, + projectPath, + "unresolved-platform", + "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing Swift package links.", source, ); } + const supportedPlatforms = canonicalPlatforms(inspectedTarget.supportedPlatforms); + const requestedSupportedPlatforms = options.supportedPlatforms + ? canonicalPlatforms(options.supportedPlatforms) + : supportedPlatforms; + if ( + supportedPlatforms.length === 0 || + requestedSupportedPlatforms.length !== supportedPlatforms.length || + requestedSupportedPlatforms.some((item, index) => item !== supportedPlatforms[index]) + ) { + return blocked( + options, + root, + projectPath, + "unresolved-platform", + "The selected target's supported iOS and macOS platforms changed after this SDK plan was created.", + source, + ); + } + if (options.platform && platform !== options.platform) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected target is no longer a verified ${ + options.platform === "macos" ? "macOS" : "iOS" + } application target.`, + source, + ); + } + options = { ...options, platform, supportedPlatforms }; + for (const supportedPlatform of supportedPlatforms) { + const platformInspection = + supportedPlatform === platform + ? inspection + : await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + platform: supportedPlatform, + }); + const platformTarget = platformInspection.appTargets.find( + (target) => target.id === options.targetId && target.projectPath === projectPath, + ); + if ( + hasIncompleteIOSContainerDiscovery(platformInspection) || + platformInspection.selection.state !== "selected" || + platformInspection.selection.targetId !== options.targetId || + platformInspection.selection.projectPath !== projectPath || + platformInspection.selection.platform !== supportedPlatform || + !platformTarget?.platformEvidenceComplete + ) { + return blocked( + options, + root, + projectPath, + "unresolved-platform", + `The selected target's ${ + supportedPlatform === "macos" ? "macOS" : "iOS" + } build settings could not be proven consistently across every configuration.`, + source, + ); + } + if ( + platformTarget.configurations.length === 0 || + platformTarget.configurations.some( + (configuration) => + configuration.deploymentTarget.state !== "resolved" || + !deploymentTargetMeetsClerkSDKFloor( + configuration.deploymentTarget.value, + supportedPlatform, + ), + ) + ) { + const label = platformLabel(supportedPlatform); + const minimum = CLERK_SDK_MINIMUM_DEPLOYMENT_TARGET[supportedPlatform]; + const setting = + supportedPlatform === "macos" ? "MACOSX_DEPLOYMENT_TARGET" : "IPHONEOS_DEPLOYMENT_TARGET"; + return blocked( + options, + root, + projectPath, + "incompatible-sdk", + `The Clerk Swift SDK requires ${label} ${minimum} or newer for every selected-target build configuration. Set ${setting} to ${minimum} or newer, make conditioned values consistent, then rerun clerk init.`, + source, + ); + } + } // Parse a second model instead of structured-cloning. pbxproj data literals // can be Buffers, which structuredClone turns into writer-incompatible @@ -1068,6 +1258,7 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise !graphs.get(productName)?.buildFileId, + const requiresFrameworkPhase = products.some((productName) => + supportedPlatforms.some((platform) => !graphs.get(productName)?.buildFileIds[platform]), ); if (!frameworkPhaseId && requiresFrameworkPhase) { frameworkPhaseId = stableObjectId( @@ -1179,7 +1370,8 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise !graph.buildFileIds[platform]); + if (missingPlatforms.length > 0) { if (!frameworkPhaseId) { return blocked( options, @@ -1190,19 +1382,39 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise item.id === plan.targetId && item.projectPath === plan.projectPath, ); - if (!target || !["remote", "local"].includes(target.packages.package)) return false; - return plan.products.every((productName) => - productName === "ClerkKit" - ? target.packages.clerkKit === "linked" - : target.packages.clerkKitUI === "linked", - ); + if ( + !target?.platformEvidenceComplete || + !["remote", "local"].includes(target.packages.package) || + canonicalPlatforms(target.supportedPlatforms).join(",") !== plan.supportedPlatforms.join(",") + ) { + return false; + } + for (const platform of plan.supportedPlatforms) { + const platformInspection = + platform === plan.platform + ? inspection + : await inspectIOSProject(plan.root, { + target: plan.targetId, + exhaustiveContainerDiscovery: true, + platform, + }); + if ( + hasIncompleteIOSContainerDiscovery(platformInspection) || + platformInspection.selection.state !== "selected" || + platformInspection.selection.targetId !== plan.targetId || + platformInspection.selection.projectPath !== plan.projectPath || + platformInspection.selection.platform !== platform + ) { + return false; + } + const platformTarget = platformInspection.appTargets.find( + (item) => item.id === plan.targetId && item.projectPath === plan.projectPath, + ); + if ( + !platformTarget?.platformEvidenceComplete || + !plan.products.every((productName) => + productName === "ClerkKit" + ? platformTarget.packages.clerkKit === "linked" + : platformTarget.packages.clerkKitUI === "linked", + ) + ) { + return false; + } + } + return true; } export async function planIOSSDKInstall(options: IOSSDKInstallOptions): Promise { @@ -1352,7 +1605,11 @@ export type PreparedIOSSDKInstallMutation = | { status: "blocked"; plan: IOSSDKInstallPlan } | { status: "stale"; plan: IOSSDKInstallPlan } | { status: "satisfied"; plan: IOSSDKInstallPlan } - | { status: "ready"; plan: IOSSDKInstallPlan; mutation: IOSExistingFileMutation }; + | { + status: "ready"; + plan: IOSSDKInstallPlan; + mutation: IOSExistingFileMutation; + }; /** * Reprepares a serialized SDK plan and exposes its PBX mutation without writing @@ -1369,6 +1626,8 @@ export async function prepareIOSSDKInstallMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, + supportedPlatforms: plan.supportedPlatforms, includeClerkKitUI: plan.products.includes("ClerkKitUI"), minimumVersion: plan.minimumVersion, requirePrebuiltAuthCompatibility: plan.requirePrebuiltAuthCompatibility, @@ -1397,6 +1656,8 @@ export async function prepareIOSSDKInstallMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, + supportedPlatforms: plan.supportedPlatforms, includeClerkKitUI: plan.products.includes("ClerkKitUI"), minimumVersion: plan.minimumVersion, requirePrebuiltAuthCompatibility: plan.requirePrebuiltAuthCompatibility, diff --git a/packages/cli-core/src/commands/init/ios/local-plan.test.ts b/packages/cli-core/src/commands/init/ios/local-plan.test.ts index cfcbd8133..f5672acf5 100644 --- a/packages/cli-core/src/commands/init/ios/local-plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/local-plan.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { inspectIOSProject } from "./inspect.ts"; import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; -import { createIOSFixture, treeDigest } from "./test-helpers.ts"; +import { convertIOSFixtureToMultiplatform, createIOSFixture, treeDigest } from "./test-helpers.ts"; import { createIOSDryRunOutput } from "./output.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; @@ -44,7 +44,7 @@ describe("iOS local setup lifecycle", () => { expect(proposal.nativeAppleRequested).toBe(false); }); - test("uses the same read-only proposal for preview and apply", async () => { + test("keeps the iOS setup plan equal between preview and apply and reruns byte-identically", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { clerkSDK: false, includeKey: false }); @@ -109,4 +109,159 @@ describe("iOS local setup lifecycle", () => { await applyIOSPlannedLocalSetup(rerun, publishableKey); expect(await treeDigest(root)).toEqual(appliedBytes); }); + + test("keeps the macOS setup plan equal between preview and apply and reruns byte-identically", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-local-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + complete: true, + includeKey: false, + localSecrets: true, + macOSAppleEntitlement: false, + }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + entitlementsPath, + (await Bun.file(entitlementsPath).text()).replace( + /\s*com\.apple\.security\.network\.client<\/key>\s*/, + "", + ), + ); + const initialBytes = await treeDigest(root); + + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + const dryRun = createIOSDryRunOutput(proposal.inspection, proposal.setupPlan, { + associatedDomainPlan: proposal.plannedAssociatedDomain, + nativeReadiness: proposal.nativeReadiness, + platformViews: proposal.platformViews, + }); + + expect(dryRun.plan).toBe(proposal.setupPlan); + expect(dryRun.nativeReadiness).toBe(proposal.nativeReadiness); + expect(proposal.associatedDomainPlan).toBeUndefined(); + expect(proposal.macOSNetworkCapabilityPlan?.status).toBe("ready"); + expect(await treeDigest(root)).toEqual(initialBytes); + + const approved = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + expect(approved.setupPlan).toEqual(proposal.setupPlan); + await applyIOSPlannedLocalSetup(approved); + const appliedBytes = await treeDigest(root); + expect(appliedBytes).not.toEqual(initialBytes); + expect(await Bun.file(entitlementsPath).text()).toContain("com.apple.security.network.client"); + + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + await applyIOSPlannedLocalSetup(rerun); + expect(await treeDigest(root)).toEqual(appliedBytes); + }); + + test("keeps the shared setup plan equal between preview and apply and reruns byte-identically", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-local-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const iOSEntitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + iOSEntitlementsPath, + (await Bun.file(iOSEntitlementsPath).text()).replace( + /\s*com\.apple\.developer\.associated-domains<\/key>\s*.*?<\/array>/s, + "", + ), + ); + const macOSEntitlementsPath = join(root, "MyApp", "MyApp.mac.entitlements"); + await Bun.write( + macOSEntitlementsPath, + 'com.apple.security.app-sandbox', + ); + await convertIOSFixtureToMultiplatform(root); + const initialBytes = await treeDigest(root); + + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + const dryRun = createIOSDryRunOutput(proposal.inspection, proposal.setupPlan, { + associatedDomainPlan: proposal.plannedAssociatedDomain, + nativeReadiness: proposal.nativeReadiness, + platformViews: proposal.platformViews, + }); + const requiredSteps = proposal.setupPlan.steps + .filter((step) => step.status === "required" && step.automatable) + .map((step) => step.id); + + expect(dryRun.plan).toBe(proposal.setupPlan); + expect(dryRun.nativeReadiness).toBe(proposal.nativeReadiness); + expect(requiredSteps).toEqual( + expect.arrayContaining(["add-associated-domain", "enable-macos-network"]), + ); + expect(await treeDigest(root)).toEqual(initialBytes); + + const approved = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + expect(approved.setupPlan).toEqual(proposal.setupPlan); + await applyIOSPlannedLocalSetup(approved, publishableKey); + const appliedBytes = await treeDigest(root); + expect(appliedBytes).not.toEqual(initialBytes); + expect(await Bun.file(iOSEntitlementsPath).text()).toContain( + "webcredentials:local-plan.clerk.example", + ); + expect(await Bun.file(iOSEntitlementsPath).text()).not.toContain( + "com.apple.security.network.client", + ); + expect(await Bun.file(macOSEntitlementsPath).text()).toContain( + "com.apple.security.network.client", + ); + expect(await Bun.file(macOSEntitlementsPath).text()).not.toContain( + "com.apple.developer.associated-domains", + ); + + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + await applyIOSPlannedLocalSetup(rerun, publishableKey); + expect(await treeDigest(root)).toEqual(appliedBytes); + }); }); diff --git a/packages/cli-core/src/commands/init/ios/local-plan.ts b/packages/cli-core/src/commands/init/ios/local-plan.ts index c3b18648e..d9a44e16f 100644 --- a/packages/cli-core/src/commands/init/ios/local-plan.ts +++ b/packages/cli-core/src/commands/init/ios/local-plan.ts @@ -10,6 +10,12 @@ import { planIOSDirectConfig, type IOSDirectConfigPlan } from "./direct-config.t import { planIOSAssociatedDomain, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import { planIOSAppleEntitlement, type IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import { planIOSSDKInstall, type IOSSDKInstallPlan } from "./install-sdk.ts"; +import { planMacOSNetworkCapability, type MacOSNetworkCapabilityPlan } from "./macos-network.ts"; +import { + inspectIOSPlatformViews, + iosPlatformViewsHaveAppleEntitlementIntent, + type IOSPlatformViewsSnapshot, +} from "./platform-views.ts"; import { buildIOSSetupPlan } from "./plan.ts"; import { buildIOSNativeReadinessAudit, @@ -41,13 +47,21 @@ export interface BuildIOSLocalSetupProposalOptions { } /** - * One credential-free, mutation-free proposal shared by dry-run and apply. - * Candidate bytes and prepared mutations never enter this structure. + * A credential-free, mutation-free aggregate proposal model rebuilt from the + * current inspection for dry-run, approval, and apply preparation. Candidate + * bytes and prepared mutations never enter this structure. */ export interface IOSLocalSetupProposal { inspection: IOSProjectInspectionResult; selectedTarget?: IOSAppTarget; productDecision?: ProductDecision; + /** Platform selected for local native Apple automation. */ + platform?: IOSAppTarget["platform"]; + /** Native Apple platforms declared or inferred for the selected target. */ + supportedPlatforms?: IOSAppTarget["supportedPlatforms"]; + /** Exhaustive, credential-free evidence shared by local and remote safety checks. */ + platformViews?: IOSPlatformViewsSnapshot; + platformCompatibilityBlockers: string[]; setupPlan: IOSSetupPlan; nativeReadiness: IOSNativeReadinessAudit; unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; @@ -63,6 +77,7 @@ export interface IOSLocalSetupProposal { directConfigPlan?: IOSDirectConfigPlan; plannedAssociatedDomain?: IOSAssociatedDomainPlan; associatedDomainPlan?: IOSAssociatedDomainPlan; + macOSNetworkCapabilityPlan?: MacOSNetworkCapabilityPlan; inspectedAppleEntitlementPlan?: IOSAppleEntitlementPlan; appleEntitlementPlan?: IOSAppleEntitlementPlan; prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; @@ -146,18 +161,52 @@ export async function buildIOSLocalSetupProposal( context: IOSLocalSetupContext, options: BuildIOSLocalSetupProposalOptions, ): Promise { - const { inspection, selectedTarget, productDecision } = context; + const { inspection, selectedTarget, productDecision: contextProductDecision } = context; const selection = inspection.selection; - if (selection.state !== "selected" || !selectedTarget || !productDecision) { + if (selection.state !== "selected" || !selectedTarget || !contextProductDecision) { const setupPlan = buildIOSSetupPlan(inspection, { prebuiltAuthSelected: options.prebuiltAuthUI === true, }); return { inspection, selectedTarget, + productDecision: contextProductDecision, + ...(selectedTarget ? { platform: selectedTarget.platform } : {}), + ...(selectedTarget ? { supportedPlatforms: [...selectedTarget.supportedPlatforms] } : {}), + setupPlan, + nativeReadiness: buildIOSNativeReadinessAudit(inspection), + platformCompatibilityBlockers: [], + prebuiltAuthRequested: options.prebuiltAuthUI === true, + prebuiltAuthActive: false, + prebuiltRuntimeBlockers: [], + reviewOnlyUnattributedInstall: false, + nativeAppleRequested: options.signInWithApple === true, + hasCustomConfigure: false, + hasSupportedCustomConfigure: false, + }; + } + + let productDecision = contextProductDecision; + + const platformViewsAudit = await inspectIOSPlatformViews(inspection); + if (platformViewsAudit.status === "blocked") { + const platformCompatibilityBlockers = platformViewsAudit.blockers.map( + (blocker) => blocker.message, + ); + const setupPlan = buildIOSSetupPlan(inspection, { productDecision, + platformCompatibilityBlockers, + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + return { + inspection, + selectedTarget, + productDecision, + platform: selectedTarget.platform, + supportedPlatforms: [...selectedTarget.supportedPlatforms], setupPlan, nativeReadiness: buildIOSNativeReadinessAudit(inspection), + platformCompatibilityBlockers, prebuiltAuthRequested: options.prebuiltAuthUI === true, prebuiltAuthActive: false, prebuiltRuntimeBlockers: [], @@ -167,11 +216,14 @@ export async function buildIOSLocalSetupProposal( hasSupportedCustomConfigure: false, }; } + const platformViews = platformViewsAudit.snapshot; + productDecision = platformViews.productDecision; const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, + platform: selectedTarget.platform, allowDirty: options.allowDirty, }); let prebuiltAuthRequested = options.prebuiltAuthUI === true; @@ -190,13 +242,16 @@ export async function buildIOSLocalSetupProposal( inspectedPrebuiltAuthPlan.status !== "blocked" && (prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"); - const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; + const includeClerkKitUI = platformViews.requiresClerkKitUI || prebuiltAuthActive; const installPlan = await planIOSSDKInstall({ root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, + platform: selectedTarget.platform, + supportedPlatforms: selectedTarget.supportedPlatforms, includeClerkKitUI, - requirePrebuiltAuthCompatibility: prebuiltAuthActive, + requirePrebuiltAuthCompatibility: + platformViews.requiresAuthViewCompatibility || prebuiltAuthActive, }); const hasCustomConfigure = selectedTarget.swift.configureCalls.some( @@ -212,6 +267,7 @@ export async function buildIOSLocalSetupProposal( root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, + platform: selectedTarget.platform, allowDirty: options.allowDirty, }) : undefined; @@ -236,24 +292,33 @@ export async function buildIOSLocalSetupProposal( : inspectedPrebuiltAuthPlan; const prebuiltAuthPlan = prebuiltAuthActive ? prebuiltAuthPlanForSetup : undefined; - const plannedAssociatedDomain = await planIOSAssociatedDomain({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - deferToPublishableKey: directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, - allowMissingEntitlementsCreation: true, - }); + const plannedAssociatedDomain = + selectedTarget.platform === "ios" + ? await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, + allowMissingEntitlementsCreation: true, + }) + : undefined; const associatedDomainPlan = - plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; + plannedAssociatedDomain?.status === "blocked" ? undefined : plannedAssociatedDomain; const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { associatedDomainPlan: plannedAssociatedDomain, + platformViews, }); + const macOSNetworkCapabilityPlan = selectedTarget.supportedPlatforms.includes("macos") + ? await planMacOSNetworkCapability({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowMissingEntitlementsCreation: true, + }) + : undefined; - const hasLocalAppleEntitlement = selectedTarget.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); + const hasLocalAppleEntitlement = iosPlatformViewsHaveAppleEntitlementIntent(platformViews); let nativeAppleRequested = options.signInWithApple === true; if ( !nativeAppleRequested && @@ -273,6 +338,8 @@ export async function buildIOSLocalSetupProposal( root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, + platform: selectedTarget.platform, + supportedPlatforms: selectedTarget.supportedPlatforms, allowMissingEntitlementsCreation: true, }) : undefined; @@ -283,6 +350,12 @@ export async function buildIOSLocalSetupProposal( : inspectedAppleEntitlementPlan?.status === "satisfied" ? inspectedAppleEntitlementPlan : undefined; + // Surface incomplete Apple capability state in previews without authorizing + // the mutating path to finish it unless this invocation explicitly opted in. + const appleEntitlementPlanForSetup = + nativeAppleRequested || hasLocalAppleEntitlement + ? inspectedAppleEntitlementPlan + : appleEntitlementPlan; const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive ? inspectedAppleEntitlementPlan : undefined; @@ -293,10 +366,12 @@ export async function buildIOSLocalSetupProposal( prebuiltAuthActive, }); const setupPlan = buildIOSSetupPlan(inspection, { + productDecision, sdkInstallPlan, directConfigPlan, associatedDomainPlan: plannedAssociatedDomain, - appleEntitlementPlan, + macOSNetworkCapabilityPlan, + appleEntitlementPlan: appleEntitlementPlanForSetup, prebuiltAuthPlan: prebuiltAuthPlanForSetup, prebuiltAuthSelected: prebuiltAuthRequested, }); @@ -306,6 +381,10 @@ export async function buildIOSLocalSetupProposal( inspection, selectedTarget, productDecision, + platform: selectedTarget.platform, + supportedPlatforms: [...selectedTarget.supportedPlatforms], + platformViews, + platformCompatibilityBlockers: [], setupPlan, nativeReadiness, ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), @@ -321,6 +400,7 @@ export async function buildIOSLocalSetupProposal( directConfigPlan, plannedAssociatedDomain, associatedDomainPlan, + macOSNetworkCapabilityPlan, inspectedAppleEntitlementPlan, appleEntitlementPlan, prebuiltAuthAppleEntitlementPlan, 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 new file mode 100644 index 000000000..a8b517f06 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/macos-network.test.ts @@ -0,0 +1,459 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, +} from "./apple-entitlement.ts"; +import { applyIOSFileTransaction } from "./file-transaction.ts"; +import { + applyMacOSNetworkCapability, + planMacOSNetworkCapability, + prepareMacOSNetworkCapabilityMutation, + validatePreparedMacOSNetworkCapability, +} from "./macos-network.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-network-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + includeKey: false, + macOSAppleEntitlement: false, + }); + return root; +} + +function pbxprojPath(root: string): string { + return join(root, "MyApp.xcodeproj", "project.pbxproj"); +} + +function entitlementsPath(root: string): string { + return join(root, "MyApp", "MyApp.entitlements"); +} + +function options(root: string, allowMissingEntitlementsCreation = false) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + allowMissingEntitlementsCreation, + }; +} + +async function updateBuildSettings( + root: string, + update: (settings: Record) => void, +): Promise { + 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.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + update(objects[id]!.buildSettings as Record); + } + await writeFile(path, buildPbxProject(project)); +} + +async function enableSandbox(root: string): Promise { + await updateBuildSettings(root, (settings) => { + settings.ENABLE_APP_SANDBOX = "YES"; + }); +} + +async function makeMultiplatform(root: string): Promise { + await updateBuildSettings(root, (settings) => { + settings.SDKROOT = "auto"; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + settings.IPHONEOS_DEPLOYMENT_TARGET = "17.0"; + settings.MACOSX_DEPLOYMENT_TARGET = "14.0"; + }); +} + +async function useSeparatePlatformEntitlements(root: string): Promise { + const iosPath = join(root, "MyApp", "MyApp.ios.entitlements"); + await writeFile( + iosPath, + '\n\n', + ); + await updateBuildSettings(root, (settings) => { + delete settings.CODE_SIGN_ENTITLEMENTS; + settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"] = "MyApp/MyApp.ios.entitlements"; + settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"] = "MyApp/MyApp.ios.entitlements"; + settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"] = "MyApp/MyApp.entitlements"; + }); + return iosPath; +} + +async function removeNetworkEntitlement(root: string): Promise { + const path = entitlementsPath(root); + const source = (await readFile(path, "utf8")).replace( + /\s*com\.apple\.security\.network\.client<\/key>\s*/, + "", + ); + await writeFile(path, source); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("macOS outgoing network capability", () => { + test("does nothing for a provably unsandboxed macOS app", async () => { + const root = await temporaryRoot(); + await updateBuildSettings(root, (settings) => { + delete settings.ENABLE_APP_SANDBOX; + }); + await writeFile( + entitlementsPath(root), + '\n\n', + ); + const before = await treeDigest(root); + + const plan = await planMacOSNetworkCapability(options(root)); + + expect(plan).toMatchObject({ status: "satisfied", files: [], blockers: [] }); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("ignores unresolved outgoing-network settings for a provably unsandboxed app", async () => { + const root = await temporaryRoot(); + await updateBuildSettings(root, (settings) => { + settings.ENABLE_APP_SANDBOX = "NO"; + settings.ENABLE_OUTGOING_NETWORK_CONNECTIONS = "$(UNRESOLVED_NETWORK_SETTING)"; + }); + await writeFile( + entitlementsPath(root), + '\n\n', + ); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "satisfied", + files: [], + blockers: [], + }); + }); + + test("adds only network.client to an existing sandboxed entitlement plist", async () => { + const root = await temporaryRoot(); + await enableSandbox(root); + await removeNetworkEntitlement(root); + const path = entitlementsPath(root); + const source = (await readFile(path, "utf8")).replace( + "com.apple.security.app-sandbox", + "\ncom.apple.security.app-sandbox", + ); + await writeFile(path, source); + + const plan = await planMacOSNetworkCapability(options(root)); + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], + blockers: [], + }); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("applied"); + + const after = await readFile(path, "utf8"); + expect(after).toContain(""); + expect(after).toContain("com.apple.security.network.client"); + expect(after).toContain(""); + expect(after).toContain("com.apple.security.app-sandbox"); + expect((await planMacOSNetworkCapability(options(root))).status).toBe("satisfied"); + }); + + test("accepts exact outgoing access supplied by entitlements", async () => { + const root = await temporaryRoot(); + await writeFile( + entitlementsPath(root), + ` + + com.apple.security.app-sandbox + com.apple.security.network.client + +`, + ); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "satisfied", + blockers: [], + }); + }); + + test("accepts an already-satisfied shared entitlement on a multiplatform target", async () => { + const root = await temporaryRoot(); + await makeMultiplatform(root); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "satisfied", + blockers: [], + }); + }); + + test("modifies only a separately attached macOS entitlement on a multiplatform target", async () => { + const root = await temporaryRoot(); + await makeMultiplatform(root); + await enableSandbox(root); + const iosPath = await useSeparatePlatformEntitlements(root); + await removeNetworkEntitlement(root); + const iosBefore = await readFile(iosPath, "utf8"); + + const plan = await planMacOSNetworkCapability(options(root)); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], + blockers: [], + }); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("applied"); + expect(await readFile(iosPath, "utf8")).toBe(iosBefore); + expect(await readFile(entitlementsPath(root), "utf8")).toContain( + "com.apple.security.network.client", + ); + }); + + test("blocks mutating an entitlement shared by iOS and macOS builds", async () => { + const root = await temporaryRoot(); + await makeMultiplatform(root); + await enableSandbox(root); + await removeNetworkEntitlement(root); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unsafe-entitlements" }], + }); + }); + + test("blocks a macOS entitlement aliased by a sibling multiplatform target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-network-sibling-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + includeKey: false, + macOSAppleEntitlement: false, + secondTarget: true, + }); + await makeMultiplatform(root); + await enableSandbox(root); + await useSeparatePlatformEntitlements(root); + await removeNetworkEntitlement(root); + 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]) { + const settings = objects[id]!.buildSettings as Record; + settings.SDKROOT = "auto"; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + settings.IPHONEOS_DEPLOYMENT_TARGET = "17.0"; + settings.MACOSX_DEPLOYMENT_TARGET = "14.0"; + settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"] = "MyApp/MyApp.entitlements"; + } + await writeFile(path, buildPbxProject(project)); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unsafe-entitlements" }], + }); + }); + + test("requires every configuration to prove macOS support", async () => { + const root = await temporaryRoot(); + await makeMultiplatform(root); + const path = pbxprojPath(root); + const project = parsePbxProject(await readFile(path, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + const release = objects[IOS_FIXTURE_IDS.targetRelease]!.buildSettings as Record< + string, + unknown + >; + release.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + await writeFile(path, buildPbxProject(project)); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unresolved-platform" }], + }); + }); + + test("reports a pure iOS target as unsupported", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-network-ios-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false }); + + await expect(planMacOSNetworkCapability(options(root))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unsupported-platform" }], + }); + }); + + test("blocks explicit false, malformed, and architecture-conflicting values", async () => { + const explicitFalse = await temporaryRoot(); + await enableSandbox(explicitFalse); + await updateBuildSettings(explicitFalse, (settings) => { + settings.ENABLE_OUTGOING_NETWORK_CONNECTIONS = "NO"; + }); + await expect(planMacOSNetworkCapability(options(explicitFalse))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "conflicting-network-setting" }], + }); + + const malformed = await temporaryRoot(); + await enableSandbox(malformed); + await writeFile( + entitlementsPath(malformed), + `com.apple.security.network.clientYES`, + ); + await expect(planMacOSNetworkCapability(options(malformed))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unsupported-entitlements" }], + }); + + const conflicting = await temporaryRoot(); + await updateBuildSettings(conflicting, (settings) => { + settings["ENABLE_APP_SANDBOX[sdk=macosx*][arch=arm64]"] = "YES"; + settings["ENABLE_APP_SANDBOX[sdk=macosx*][arch=x86_64]"] = "NO"; + }); + await expect(planMacOSNetworkCapability(options(conflicting))).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unresolved-sandbox-setting" }], + }); + }); + + test("creates a macOS-only entitlement setting and both required capabilities", async () => { + const root = await temporaryRoot(); + await enableSandbox(root); + const projectPath = pbxprojPath(root); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + const synchronizedRootId = "515151515151515151515151"; + const mainGroup = objects[IOS_FIXTURE_IDS.mainGroup]!; + mainGroup.children = [ + ...(mainGroup.children as string[]).filter((id) => id !== IOS_FIXTURE_IDS.entitlementsFile), + synchronizedRootId, + ]; + objects[synchronizedRootId] = { + isa: "PBXFileSystemSynchronizedRootGroup", + path: "MyApp", + sourceTree: "", + }; + objects[IOS_FIXTURE_IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; + delete objects[IOS_FIXTURE_IDS.entitlementsFile]; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + delete settings.CODE_SIGN_ENTITLEMENTS; + } + await writeFile(projectPath, buildPbxProject(project)); + await rm(entitlementsPath(root)); + + const plan = await planMacOSNetworkCapability(options(root, true)); + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], + missingEntitlementsSettings: { platform: "macos", status: "ready" }, + }); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("applied"); + + const afterProject = parsePbxProject(await readFile(projectPath, "utf8")); + const afterObjects = (afterProject as unknown as { objects: PbxObjects }).objects; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = afterObjects[id]!.buildSettings as Record; + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBeUndefined(); + } + const entitlements = await readFile(entitlementsPath(root), "utf8"); + expect(entitlements).toContain("com.apple.security.app-sandbox"); + expect(entitlements).toContain("com.apple.security.network.client"); + expect((await planMacOSNetworkCapability(options(root))).status).toBe("satisfied"); + }); + + test("creates a distinct macOS entitlement for a multiplatform target", async () => { + const root = await temporaryRoot(); + await makeMultiplatform(root); + await enableSandbox(root); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await updateBuildSettings(root, (settings) => { + delete settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]; + }); + + const plan = await planMacOSNetworkCapability(options(root, true)); + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.mac.entitlements", operation: "create" }], + missingEntitlementsSettings: { + platform: "macos", + buildSettingPath: "MyApp/MyApp.mac.entitlements", + status: "ready", + }, + }); + expect((await applyMacOSNetworkCapability(plan)).status).toBe("applied"); + + const project = parsePbxProject(await readFile(pbxprojPath(root), "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.mac.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBeUndefined(); + } + expect(await readFile(join(root, "MyApp", "MyApp.mac.entitlements"), "utf8")).toContain( + "com.apple.security.network.client", + ); + }); + + test("composes its candidate with a later Sign in with Apple entitlement", async () => { + const root = await temporaryRoot(); + await enableSandbox(root); + await removeNetworkEntitlement(root); + const networkPlan = await planMacOSNetworkCapability(options(root)); + const network = await prepareMacOSNetworkCapabilityMutation(networkPlan); + expect(network.status).toBe("ready"); + if (network.status !== "ready") throw new Error("Expected a network mutation."); + + const applePlan = await planIOSAppleEntitlement({ + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + platform: "macos", + }); + const apple = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: network.mutations, + }); + expect(apple.status).toBe("ready"); + if (apple.status !== "ready") throw new Error("Expected a composed Apple mutation."); + expect(apple.consumedBaseMutationPaths).toContain(entitlementsPath(root)); + + const applied = await applyIOSFileTransaction(apple.mutations, [ + () => validatePreparedMacOSNetworkCapability(network), + () => validatePreparedIOSAppleEntitlement(apple), + ]); + expect(applied.status).toBe("applied"); + const source = await readFile(entitlementsPath(root), "utf8"); + expect(source).toContain("com.apple.security.network.client"); + expect(source).toContain("com.apple.developer.applesignin"); + }); + + test("does not serialize prepared entitlement bytes", async () => { + const root = await temporaryRoot(); + await enableSandbox(root); + await removeNetworkEntitlement(root); + const prepared = await prepareMacOSNetworkCapabilityMutation( + await planMacOSNetworkCapability(options(root)), + ); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/macos-network.ts b/packages/cli-core/src/commands/init/ios/macos-network.ts new file mode 100644 index 000000000..85f952033 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/macos-network.ts @@ -0,0 +1,986 @@ +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 { readBoundedRegularFile } from "./bounded-file.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + planIOSMissingEntitlementsSettings, + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { isRecord } from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; +import type { IOSAppTarget, IOSValueResolution } from "./types.ts"; + +const APP_SANDBOX_KEY = "com.apple.security.app-sandbox"; +const NETWORK_CLIENT_KEY = "com.apple.security.network.client"; +const MAX_ENTITLEMENTS_BYTES = 1_000_000; + +export type MacOSNetworkCapabilityBlockerCode = + | "invalid-selection" + | "unsupported-platform" + | "unresolved-platform" + | "unresolved-sandbox-setting" + | "conflicting-sandbox-setting" + | "unresolved-network-setting" + | "conflicting-network-setting" + | "missing-entitlements" + | "unsafe-entitlements" + | "unreadable-entitlements" + | "unsupported-entitlements" + | "conflicting-entitlement" + | "stale-entitlements" + | "invalid-plan"; + +export interface MacOSNetworkCapabilityBlocker { + code: MacOSNetworkCapabilityBlockerCode; + message: string; +} + +export interface MacOSNetworkCapabilityPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface MacOSNetworkCapabilityPlan { + schemaVersion: 1; + kind: "clerk-macos-network-capability"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + targetName?: string; + files: MacOSNetworkCapabilityPlanFile[]; + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + actions: string[]; + blockers: MacOSNetworkCapabilityBlocker[]; +} + +export interface MacOSNetworkCapabilityPlanOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; + /** Allows a synchronized app root to receive a new macOS entitlements file. */ + allowMissingEntitlementsCreation?: boolean; +} + +export interface MacOSNetworkCapabilityPrepareOptions { + /** Previously prepared candidates to compose with without exposing their bytes. */ + baseMutations?: readonly IOSFileMutation[]; +} + +export type PreparedMacOSNetworkCapabilityMutation = + | { status: "satisfied"; plan: MacOSNetworkCapabilityPlan } + | { status: "blocked"; plan: MacOSNetworkCapabilityPlan } + | { status: "stale"; plan: MacOSNetworkCapabilityPlan } + | { + status: "ready"; + plan: MacOSNetworkCapabilityPlan; + /** @internal Candidate bytes must never be serialized into output or telemetry. */ + mutations: IOSFileMutation[]; + /** Absolute caller-supplied candidates consumed by this preparation. */ + consumedBaseMutationPaths: string[]; + }; + +interface EntitlementsDocument { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + appSandbox: BooleanEntitlementState; + networkClient: BooleanEntitlementState; +} + +type BooleanEntitlementState = "absent" | "true" | "false" | "invalid"; + +type EntitlementsInspection = + | { status: "safe"; document: EntitlementsDocument } + | { status: "blocked"; blocker: MacOSNetworkCapabilityBlocker }; + +type BooleanBuildSettingState = "missing" | "true" | "false" | "invalid"; + +function blocker( + code: MacOSNetworkCapabilityBlockerCode, + message: string, +): MacOSNetworkCapabilityBlocker { + return { code, message }; +} + +function planBase(options: MacOSNetworkCapabilityPlanOptions) { + return { + schemaVersion: 1 as const, + kind: "clerk-macos-network-capability" as const, + root: resolve(options.root), + projectPath: options.projectPath.replaceAll("\\", "/"), + targetId: options.targetId, + }; +} + +function blockedPlan( + options: MacOSNetworkCapabilityPlanOptions, + blockers: MacOSNetworkCapabilityBlocker[], + targetName?: string, +): MacOSNetworkCapabilityPlan { + return { + ...planBase(options), + status: "blocked", + ...(targetName ? { targetName } : {}), + files: [], + actions: [], + blockers, + }; +} + +function blockPrepared( + plan: MacOSNetworkCapabilityPlan, + code: MacOSNetworkCapabilityBlockerCode, + message: string, +): Extract { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + files: [], + actions: [], + blockers: [blocker(code, message)], + }, + }; +} + +function selectedTarget( + inspection: Awaited>, + projectPath: string, + targetId: string, +): IOSAppTarget | undefined { + if ( + inspection.selection.state !== "selected" || + inspection.selection.projectPath !== projectPath || + inspection.selection.targetId !== targetId + ) { + return undefined; + } + return inspection.appTargets.find( + (target) => target.projectPath === projectPath && target.id === targetId, + ); +} + +function booleanBuildSetting(resolution: IOSValueResolution | undefined): BooleanBuildSettingState { + if (!resolution || resolution.state === "missing") return "missing"; + if (resolution.state === "unresolved") return "invalid"; + const value = resolution.value.trim().toUpperCase(); + if (value === "YES") return "true"; + if (value === "NO") return "false"; + return "invalid"; +} + +function uniqueStates(states: readonly BooleanBuildSettingState[]): Set { + return new Set(states); +} + +function stripXMLCommentsPreservingOffsets(source: string): string { + return source.replace(//g, (comment) => " ".repeat(comment.length)); +} + +function literalKeyCount(source: string, key: string): number { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return [ + ...stripXMLCommentsPreservingOffsets(source).matchAll( + new RegExp(`]*>\\s*${escaped}\\s*`, "g"), + ), + ].length; +} + +function booleanEntitlementState( + source: string, + parsed: Record, + key: string, +): BooleanEntitlementState { + const present = Object.hasOwn(parsed, key); + const count = literalKeyCount(source, key); + if ((present && count !== 1) || (!present && count !== 0)) return "invalid"; + if (!present) return "absent"; + const value = parsed[key]; + if (value === true) return "true"; + if (value === false) return "false"; + return "invalid"; +} + +function inspectEntitlementsBytes( + root: string, + absolutePath: string, + bytes: Uint8Array, + mode: number, +): EntitlementsInspection { + const relativePath = relativeIOSPath(root, absolutePath); + try { + if (bytes.byteLength > MAX_ENTITLEMENTS_BYTES) throw new Error("too large"); + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} must be a UTF-8 XML plist before automatic setup.`, + ), + }; + } + const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + const source = new TextDecoder("utf-8", { fatal: true }).decode(bom ? bytes.slice(3) : bytes); + const parsed = parseIOSPlist(source); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + const appSandbox = booleanEntitlementState(source, parsed, APP_SANDBOX_KEY); + const networkClient = booleanEntitlementState(source, parsed, NETWORK_CLIENT_KEY); + if (appSandbox === "invalid" || networkClient === "invalid") { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} has malformed or non-literal macOS sandbox entitlements.`, + ), + }; + } + return { + status: "safe", + document: { + absolutePath, + relativePath, + bytes, + hash: hashIOSFileBytes(bytes), + mode, + source, + bom, + appSandbox, + networkClient, + }, + }; + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativePath} could not be read as a bounded UTF-8 XML plist dictionary.`, + ), + }; + } +} + +async function inspectEntitlementsFile( + root: string, + absolutePath: string, +): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + status: "blocked", + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + const read = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (read.status !== "ok") { + return { + status: "blocked", + blocker: blocker( + read.status === "too-large" ? "unsupported-entitlements" : "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} must be a regular XML plist no larger than 1 MB.`, + ), + }; + } + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink()) throw new Error("unsupported file"); + return inspectEntitlementsBytes(root, absolutePath, read.bytes, info.mode & 0o7777); + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be inspected safely.`, + ), + }; + } +} + +function lineIndentAt(source: string, index: number): string { + const start = source.lastIndexOf("\n", index - 1) + 1; + return /^[\t ]*/.exec(source.slice(start, index))?.[0] ?? ""; +} + +function addBooleanEntitlement(source: string, key: string): string | undefined { + if (literalKeyCount(source, key) !== 0) return undefined; + const structural = stripXMLCommentsPreservingOffsets(source); + const dictClose = structural.lastIndexOf(""); + if (dictClose < 0) return undefined; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const closingIndent = lineIndentAt(source, dictClose); + const insertionPoint = dictClose - closingIndent.length; + const firstKey = /${key}${newline}${childIndent}${newline}`; + return `${source.slice(0, insertionPoint)}${insertion}${closingIndent}${source.slice(dictClose)}`; +} + +function bytesWithOptionalBOM(source: string, bom: boolean): Uint8Array { + const encoded = new TextEncoder().encode(source); + if (!bom) return encoded; + const bytes = new Uint8Array(encoded.length + 3); + bytes.set([0xef, 0xbb, 0xbf]); + bytes.set(encoded, 3); + return bytes; +} + +function newEntitlementsBytes(): Uint8Array { + return new TextEncoder().encode( + [ + '', + '', + '', + "", + `\t${APP_SANDBOX_KEY}`, + "\t", + `\t${NETWORK_CLIENT_KEY}`, + "\t", + "", + "", + "", + ].join("\n"), + ); +} + +function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { + return "kind" in mutation && mutation.kind === "create"; +} + +function validBaseMutation(mutation: IOSFileMutation): boolean { + return ( + isAbsolute(mutation.path) && + Number.isInteger(mutation.mode) && + mutation.mode >= 0 && + mutation.mode <= 0o7777 && + hashIOSFileBytes(mutation.candidateBytes) === mutation.candidateHash && + (isCreateMutation(mutation) || + hashIOSFileBytes(mutation.originalBytes) === mutation.originalHash) + ); +} + +function preparedWithHiddenMutations( + plan: MacOSNetworkCapabilityPlan, + mutations: IOSFileMutation[], + consumedBaseMutationPaths: string[], +): Extract { + const result = { + status: "ready" as const, + plan, + consumedBaseMutationPaths: [...consumedBaseMutationPaths].sort(), + } as Extract; + Object.defineProperty(result, "mutations", { + value: mutations, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +function samePlanFiles( + left: readonly MacOSNetworkCapabilityPlanFile[], + right: readonly MacOSNetworkCapabilityPlanFile[], +): boolean { + return ( + left.length === right.length && + left.every( + (file, index) => + file.path === right[index]?.path && + file.operation === right[index]?.operation && + file.expectedHash === right[index]?.expectedHash, + ) + ); +} + +function candidateWithNetwork( + root: string, + document: EntitlementsDocument, + ensureAppSandbox: boolean, +): Uint8Array | undefined { + if (document.networkClient === "false" || document.networkClient === "invalid") return undefined; + if (document.appSandbox === "false" || document.appSandbox === "invalid") return undefined; + let source = document.source; + if (ensureAppSandbox && document.appSandbox === "absent") { + const next = addBooleanEntitlement(source, APP_SANDBOX_KEY); + if (!next) return undefined; + source = next; + } + if (document.networkClient === "absent") { + const next = addBooleanEntitlement(source, NETWORK_CLIENT_KEY); + if (!next) return undefined; + source = next; + } + const bytes = bytesWithOptionalBOM(source, document.bom); + const inspected = inspectEntitlementsBytes(root, document.absolutePath, bytes, document.mode); + return inspected.status === "safe" && + inspected.document.networkClient === "true" && + (!ensureAppSandbox || inspected.document.appSandbox === "true") + ? bytes + : undefined; +} + +/** Plans only the outgoing-network requirement for a sandboxed native macOS target. */ +export async function planMacOSNetworkCapability( + options: MacOSNetworkCapabilityPlanOptions, +): Promise { + const normalized = { ...options, root: resolve(options.root) }; + const inspection = await inspectIOSProject(normalized.root, { + target: normalized.targetId, + exhaustiveContainerDiscovery: true, + platform: "macos", + }); + const target = selectedTarget(inspection, normalized.projectPath, normalized.targetId); + if (!target) { + return blockedPlan(normalized, [ + blocker( + "invalid-selection", + "The selected native application target could not be resolved exactly.", + ), + ]); + } + if (!target.supportedPlatforms.includes("macos")) { + return blockedPlan( + normalized, + [blocker("unsupported-platform", "The selected target does not support macOS.")], + target.name, + ); + } + if (!target.platformEvidenceComplete || target.platform !== "macos") { + return blockedPlan( + normalized, + [ + blocker( + "unresolved-platform", + "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing macOS capabilities.", + ), + ], + target.name, + ); + } + if (target.configurations.length === 0) { + return blockedPlan( + normalized, + [ + blocker( + "unresolved-sandbox-setting", + "The selected target has no inspectable build configurations.", + ), + ], + target.name, + ); + } + + const sandboxStates = target.configurations.map((configuration) => + booleanBuildSetting(configuration.appSandbox), + ); + const sandboxSet = uniqueStates(sandboxStates); + if (sandboxSet.has("invalid")) { + return blockedPlan( + normalized, + [ + blocker( + "unresolved-sandbox-setting", + "ENABLE_APP_SANDBOX could not be resolved to YES, NO, or absence for every macOS build context.", + ), + ], + target.name, + ); + } + if (sandboxSet.has("true") && sandboxSet.size > 1) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-sandbox-setting", + "ENABLE_APP_SANDBOX differs across the selected target's build configurations.", + ), + ], + target.name, + ); + } + + const resolvedPaths = target.configurations.flatMap((configuration) => + configuration.entitlementsPath.state === "resolved" + ? [configuration.entitlementsPath.value] + : [], + ); + const allEntitlementsMissing = target.configurations.every( + (configuration) => configuration.entitlementsPath.state === "missing", + ); + if (!allEntitlementsMissing && resolvedPaths.length !== target.configurations.length) { + return blockedPlan( + normalized, + [ + blocker( + "missing-entitlements", + "macOS entitlements paths are mixed, unresolved, or only partially configured across build configurations.", + ), + ], + target.name, + ); + } + + let files: MacOSNetworkCapabilityPlanFile[] = []; + let documents: EntitlementsDocument[] = []; + if (!allEntitlementsMissing) { + for (const configuredPath of new Set(resolvedPaths)) { + const absolutePath = resolve(normalized.root, normalized.projectPath, "..", configuredPath); + const inspected = await inspectEntitlementsFile(normalized.root, absolutePath); + if (inspected.status === "blocked") { + return blockedPlan(normalized, [inspected.blocker], target.name); + } + documents.push(inspected.document); + } + documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + files = documents.map((document) => ({ + path: document.relativePath, + operation: "modify" as const, + expectedHash: document.hash, + })); + } + + const hasExplicitSandboxNo = sandboxSet.has("false"); + const allSandboxBuildSettingsYes = sandboxSet.size === 1 && sandboxSet.has("true"); + const entitlementSandboxStates = new Set(documents.map((document) => document.appSandbox)); + if ( + entitlementSandboxStates.has("invalid") || + (entitlementSandboxStates.has("true") && entitlementSandboxStates.has("false")) || + (!allSandboxBuildSettingsYes && + entitlementSandboxStates.has("true") && + entitlementSandboxStates.has("absent")) + ) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-entitlement", + "The App Sandbox entitlement is malformed or differs across active macOS entitlements files.", + ), + ], + target.name, + ); + } + if (allSandboxBuildSettingsYes && entitlementSandboxStates.has("false")) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-sandbox-setting", + "ENABLE_APP_SANDBOX is YES but an active entitlement explicitly disables App Sandbox.", + ), + ], + target.name, + ); + } + if (hasExplicitSandboxNo && entitlementSandboxStates.has("true")) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-sandbox-setting", + "ENABLE_APP_SANDBOX is NO while an active entitlement enables App Sandbox.", + ), + ], + target.name, + ); + } + + const sandboxed = allSandboxBuildSettingsYes || entitlementSandboxStates.has("true"); + if (!sandboxed) { + return { + ...planBase(normalized), + status: "satisfied", + targetName: target.name, + files: [], + actions: [], + blockers: [], + }; + } + + const outgoingStates = target.configurations.map((configuration) => + booleanBuildSetting(configuration.outgoingNetworkConnections), + ); + const outgoingSet = uniqueStates(outgoingStates); + if (outgoingSet.has("invalid")) { + return blockedPlan( + normalized, + [ + blocker( + "unresolved-network-setting", + "ENABLE_OUTGOING_NETWORK_CONNECTIONS could not be resolved to YES, NO, or absence for every macOS build context.", + ), + ], + target.name, + ); + } + + if (outgoingSet.has("false")) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-network-setting", + "Outgoing network access is explicitly disabled in a sandboxed macOS configuration.", + ), + ], + target.name, + ); + } + if (documents.some((document) => document.networkClient === "false")) { + return blockedPlan( + normalized, + [ + blocker( + "conflicting-entitlement", + "An active entitlements file explicitly disables outgoing network access.", + ), + ], + target.name, + ); + } + if ( + (outgoingSet.size === 1 && outgoingSet.has("true")) || + (documents.length > 0 && documents.every((document) => document.networkClient === "true")) + ) { + return { + ...planBase(normalized), + status: "satisfied", + targetName: target.name, + files, + actions: [], + blockers: [], + }; + } + + if (allEntitlementsMissing) { + if (!options.allowMissingEntitlementsCreation) { + return blockedPlan( + normalized, + [ + blocker( + "missing-entitlements", + "The sandboxed macOS target has no entitlements file to receive outgoing network access.", + ), + ], + target.name, + ); + } + const settingsPlan = await planIOSMissingEntitlementsSettings({ + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + platform: "macos", + }); + if (settingsPlan.status !== "ready" || !settingsPlan.entitlementsPath) { + return blockedPlan( + normalized, + settingsPlan.blockers.length > 0 + ? settingsPlan.blockers.map((item) => blocker("missing-entitlements", item.message)) + : [ + blocker( + "missing-entitlements", + "A safe macOS entitlements destination could not be prepared.", + ), + ], + target.name, + ); + } + files = [{ path: settingsPlan.entitlementsPath, operation: "create" }]; + return { + ...planBase(normalized), + status: "ready", + targetName: target.name, + files, + missingEntitlementsSettings: settingsPlan, + actions: [ + `Create and attach ${settingsPlan.entitlementsPath} for macOS with App Sandbox and outgoing network access enabled.`, + ], + blockers: [], + }; + } + + const ownershipProbe = await planIOSAssociatedDomain({ + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + platform: "macos", + deferToPublishableKey: true, + }); + if (ownershipProbe.status === "blocked") { + return blockedPlan( + normalized, + ownershipProbe.blockers.map((item) => + blocker( + item.code === "shared-entitlements" ? "unsafe-entitlements" : "unsupported-entitlements", + item.message, + ), + ), + target.name, + ); + } + files = ownershipProbe.files.map((file) => ({ + path: file.path, + operation: "modify" as const, + ...(file.expectedHash ? { expectedHash: file.expectedHash } : {}), + })); + + return { + ...planBase(normalized), + status: "ready", + targetName: target.name, + files, + actions: ["Enable outgoing network access in every active macOS entitlements file."], + blockers: [], + }; +} + +export async function prepareMacOSNetworkCapabilityMutation( + plan: MacOSNetworkCapabilityPlan, + options: MacOSNetworkCapabilityPrepareOptions = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-macos-network-capability" || + resolve(plan.root) !== plan.root || + !plan.projectPath || + !plan.targetId + ) { + return blockPrepared(plan, "invalid-plan", "The serialized macOS network plan is incomplete."); + } + + const baseByPath = new Map(); + for (const mutation of options.baseMutations ?? []) { + const path = resolve(mutation.path); + if ( + path !== mutation.path || + baseByPath.has(path) || + !(await pathIsSafelyWithinIOSRoot(plan.root, path)) || + !validBaseMutation(mutation) + ) { + return blockPrepared( + plan, + "invalid-plan", + "A base mutation is invalid, duplicated, or outside the invocation root.", + ); + } + baseByPath.set(path, mutation); + } + + const replanned = await planMacOSNetworkCapability({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if ( + replanned.status !== plan.status || + !samePlanFiles(plan.files, replanned.files) || + Boolean(replanned.missingEntitlementsSettings) !== Boolean(plan.missingEntitlementsSettings) + ) { + return { status: "stale", plan }; + } + if (plan.status === "satisfied") return { status: "satisfied", plan: replanned }; + + const createFile = plan.files.find((file) => file.operation === "create"); + if (createFile) { + if ( + plan.files.length !== 1 || + !plan.missingEntitlementsSettings || + createFile.path !== plan.missingEntitlementsSettings.entitlementsPath + ) { + return blockPrepared( + plan, + "invalid-plan", + "The missing-entitlements macOS network plan is inconsistent.", + ); + } + const entitlementsPath = resolve(plan.root, createFile.path); + const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const baseEntitlements = baseByPath.get(entitlementsPath); + const basePbx = baseByPath.get(pbxprojPath); + if (baseEntitlements && !isCreateMutation(baseEntitlements)) return { status: "stale", plan }; + if (basePbx && isCreateMutation(basePbx)) { + return blockPrepared( + plan, + "invalid-plan", + "The base Xcode mutation must replace an existing project file.", + ); + } + const preparedSettings = await prepareIOSMissingEntitlementsSettingsMutation( + plan.missingEntitlementsSettings, + basePbx as IOSExistingFileMutation | undefined, + ); + if (preparedSettings.status === "stale") return { status: "stale", plan }; + if (preparedSettings.status !== "ready") { + return blockPrepared( + plan, + "invalid-plan", + "The macOS entitlements build setting could not be prepared safely.", + ); + } + const boundary = await prepareIOSFileMutationBoundary(plan.root, entitlementsPath); + const expectedParent = plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + const synchronizedRoot = plan.missingEntitlementsSettings.synchronizedRootPath; + if ( + !boundary || + !expectedParent || + !synchronizedRoot || + dirname(entitlementsPath) !== resolve(plan.root, synchronizedRoot) || + boundary.parentIdentity.device !== expectedParent.device || + boundary.parentIdentity.inode !== expectedParent.inode + ) { + return { status: "stale", plan }; + } + let createMutation: IOSCreateFileMutation; + if (baseEntitlements) { + if (!isDeepStrictEqual(baseEntitlements.boundary, boundary)) return { status: "stale", plan }; + const inspected = inspectEntitlementsBytes( + plan.root, + entitlementsPath, + baseEntitlements.candidateBytes, + baseEntitlements.mode, + ); + if (inspected.status === "blocked") + return blockPrepared(plan, inspected.blocker.code, inspected.blocker.message); + const candidateBytes = candidateWithNetwork(plan.root, inspected.document, true); + if (!candidateBytes) { + return blockPrepared( + plan, + "conflicting-entitlement", + "The composed entitlements candidate conflicts with the required macOS sandbox capabilities.", + ); + } + createMutation = { + ...baseEntitlements, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + }; + } else { + const candidateBytes = newEntitlementsBytes(); + createMutation = { + kind: "create", + path: entitlementsPath, + boundary, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: 0o644, + }; + } + return preparedWithHiddenMutations( + plan, + [createMutation, preparedSettings.mutation], + [...(baseEntitlements ? [entitlementsPath] : []), ...(basePbx ? [pbxprojPath] : [])], + ); + } + + const mutations: IOSExistingFileMutation[] = []; + const consumed: string[] = []; + for (const file of plan.files) { + if (file.operation !== "modify" || !file.expectedHash) { + return blockPrepared(plan, "invalid-plan", "A planned macOS entitlements file is invalid."); + } + const absolutePath = resolve(plan.root, file.path); + const current = await inspectEntitlementsFile(plan.root, absolutePath); + if (current.status === "blocked" || current.document.hash !== file.expectedHash) { + return { status: "stale", plan }; + } + const base = baseByPath.get(absolutePath); + if (base && isCreateMutation(base)) return { status: "stale", plan }; + const boundary = await prepareIOSFileMutationBoundary(plan.root, absolutePath); + if (!boundary || (base && !isDeepStrictEqual(base.boundary, boundary))) { + return { status: "stale", plan }; + } + if ( + base && + (base.originalHash !== file.expectedHash || + base.mode !== current.document.mode || + hashIOSFileBytes(base.originalBytes) !== current.document.hash) + ) { + return { status: "stale", plan }; + } + const source = base + ? inspectEntitlementsBytes(plan.root, absolutePath, base.candidateBytes, base.mode) + : current; + if (source.status === "blocked") + return blockPrepared(plan, source.blocker.code, source.blocker.message); + const candidateBytes = candidateWithNetwork(plan.root, source.document, false); + if (!candidateBytes) { + return blockPrepared( + plan, + "conflicting-entitlement", + `${file.path} has a conflicting macOS sandbox capability.`, + ); + } + if (hashIOSFileBytes(candidateBytes) === current.document.hash && !base) continue; + mutations.push({ + path: absolutePath, + boundary: base?.boundary ?? boundary, + originalBytes: base?.originalBytes ?? current.document.bytes, + originalHash: base?.originalHash ?? current.document.hash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: base?.mode ?? current.document.mode, + }); + if (base) consumed.push(absolutePath); + } + if (mutations.length === 0) return { status: "satisfied", plan }; + return preparedWithHiddenMutations(plan, mutations, consumed); +} + +export async function validatePreparedMacOSNetworkCapability( + prepared: Extract, +): Promise { + if ( + prepared.plan.missingEntitlementsSettings && + !(await validateIOSMissingEntitlementsSettingsPostcondition( + prepared.plan.missingEntitlementsSettings, + )) + ) { + return false; + } + const current = await planMacOSNetworkCapability({ + root: prepared.plan.root, + projectPath: prepared.plan.projectPath, + targetId: prepared.plan.targetId, + }); + return current.status === "satisfied"; +} + +export async function applyMacOSNetworkCapability(plan: MacOSNetworkCapabilityPlan): Promise<{ + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: MacOSNetworkCapabilityPlan; +}> { + const prepared = await prepareMacOSNetworkCapabilityMutation(plan); + if (prepared.status !== "ready") return { status: prepared.status, plan: prepared.plan }; + const result = await applyIOSFileTransaction(prepared.mutations, [ + async () => validatePreparedMacOSNetworkCapability(prepared), + ]); + return { status: result.status, plan: prepared.plan }; +} diff --git a/packages/cli-core/src/commands/init/ios/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts index ad831fa61..12084cceb 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -495,7 +495,7 @@ describe("native Sign in with Apple remote setup", () => { expect(plan.status).toBe("satisfied"); expect(harness.patchCalls).toHaveLength(0); if (plan.status === "satisfied") { - await applyIOSNativeAppleConnection(plan, harness.api); + await applyIOSNativeAppleConnection(plan, { api: harness.api }); } expect(harness.patchCalls).toHaveLength(0); expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); @@ -503,6 +503,32 @@ describe("native Sign in with Apple remote setup", () => { expect(captured.err).toContain("already enabled"); }); + test("revalidates caller-owned local state before accepting a no-op", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + let revalidations = 0; + + await expect( + applyIOSNativeAppleConnection(plan, { + api: harness.api, + revalidateLocalPreconditions: async () => { + revalidations += 1; + throw new Error("secondary platform identity changed"); + }, + }), + ).rejects.toThrow("secondary platform identity changed"); + + expect(revalidations).toBe(1); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + test("normalizes a case-only Apple config difference to the registration's spelling", async () => { const harness = statefulAPI({ initial: connection(true, true, { bundle_id: "com.example.nativeapple" }), @@ -519,7 +545,7 @@ describe("native Sign in with Apple remote setup", () => { blockers: [], }); if (plan.status !== "ready") throw new Error("expected ready plan"); - await applyIOSNativeAppleConnection(plan, harness.api); + await applyIOSNativeAppleConnection(plan, { api: harness.api }); expect(harness.patchCalls).toHaveLength(2); expect(harness.patchCalls.map((call) => call.config)).toEqual([ @@ -554,7 +580,7 @@ describe("native Sign in with Apple remote setup", () => { expect(plan.status).toBe("satisfied"); if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); - await applyIOSNativeAppleConnection(plan, harness.api); + await applyIOSNativeAppleConnection(plan, { api: harness.api }); expect(harness.patchCalls).toHaveLength(0); expect(harness.actualWrites()).toBe(0); @@ -580,7 +606,7 @@ describe("native Sign in with Apple remote setup", () => { let thrown: unknown; try { - await applyIOSNativeAppleConnection(plan, harness.api); + await applyIOSNativeAppleConnection(plan, { api: harness.api }); } catch (error) { thrown = error; } @@ -615,7 +641,7 @@ describe("native Sign in with Apple remote setup", () => { // registration transaction has run. expect(harness.patchCalls).toHaveLength(0); - await applyIOSNativeAppleConnection(prepared, harness.api); + await applyIOSNativeAppleConnection(prepared, { api: harness.api }); expect(harness.actualWrites()).toBe(1); expect(harness.patchCalls).toHaveLength(2); @@ -641,6 +667,37 @@ describe("native Sign in with Apple remote setup", () => { }); }); + test("reuses the narrow native Apple connection path for a macOS target", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection(baseOptions({ platform: "macos" }), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared).toMatchObject({ status: "ready", platform: "macos" }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await applyIOSNativeAppleConnection(prepared, { api: harness.api }); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls.map((call) => call.config)).toEqual([ + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + ]); + }); + test("requires the exact native Bundle ID even when Apple is already authenticatable", async () => { const harness = statefulAPI({ initial: connection(true, true) }); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { @@ -813,7 +870,7 @@ describe("native Sign in with Apple remote setup", () => { if (prepared.status !== "ready") throw new Error("expected ready plan"); harness.setVersion(NEXT_CONFIG_VERSION); - await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + await expect(applyIOSNativeAppleConnection(prepared, { api: harness.api })).rejects.toThrow( "changed after the approved preview", ); expect(harness.patchCalls).toHaveLength(0); @@ -843,7 +900,9 @@ describe("native Sign in with Apple remote setup", () => { if (prepared.status !== "ready") throw new Error("expected ready plan"); const incomplete = { ...prepared, configVersion: undefined }; - await expect(applyIOSNativeAppleConnection(incomplete, harness.api)).rejects.toMatchObject({ + await expect( + applyIOSNativeAppleConnection(incomplete, { api: harness.api }), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_PLAN_INVALID, }); expect(harness.patchCalls).toHaveLength(0); @@ -858,7 +917,7 @@ describe("native Sign in with Apple remote setup", () => { }); if (prepared.status !== "ready") throw new Error("expected ready plan"); - await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + await expect(applyIOSNativeAppleConnection(prepared, { api: harness.api })).rejects.toThrow( "could not safely validate native Sign in with Apple", ); expect(harness.actualWrites()).toBe(0); @@ -880,7 +939,7 @@ describe("native Sign in with Apple remote setup", () => { }); if (prepared.status !== "ready") throw new Error("expected ready plan"); - await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + await expect(applyIOSNativeAppleConnection(prepared, { api: harness.api })).rejects.toThrow( "could not safely validate native Sign in with Apple", ); expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); @@ -907,7 +966,7 @@ describe("native Sign in with Apple remote setup", () => { }); if (prepared.status !== "ready") throw new Error("expected ready plan"); - await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + await expect(applyIOSNativeAppleConnection(prepared, { api: harness.api })).rejects.toThrow( "could not safely validate native Sign in with Apple", ); expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); @@ -915,6 +974,32 @@ describe("native Sign in with Apple remote setup", () => { expect(captured.err).not.toContain(PRIVATE_KEY); }); + test("revalidates caller-owned local state after preflight and before the actual write", async () => { + const harness = statefulAPI({ + initial: connection(false, false), + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + let revalidations = 0; + + await expect( + applyIOSNativeAppleConnection(prepared, { + api: harness.api, + revalidateLocalPreconditions: async () => { + revalidations += 1; + throw new Error("secondary platform identity changed"); + }, + }), + ).rejects.toThrow("secondary platform identity changed"); + + expect(revalidations).toBe(1); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + }); + test("rejects an actual-write projection that changes a preserved credential value", async () => { const changedSecret = `${PRIVATE_KEY}_CHANGED`; const harness = statefulAPI({ @@ -929,7 +1014,7 @@ describe("native Sign in with Apple remote setup", () => { let thrown: unknown; try { - await applyIOSNativeAppleConnection(prepared, harness.api); + await applyIOSNativeAppleConnection(prepared, { api: harness.api }); } catch (error) { thrown = error; } @@ -967,7 +1052,7 @@ describe("native Sign in with Apple remote setup", () => { let thrown: unknown; try { - await applyIOSNativeAppleConnection(prepared, harness.api); + await applyIOSNativeAppleConnection(prepared, { api: harness.api }); } catch (error) { thrown = error; } @@ -990,7 +1075,9 @@ describe("native Sign in with Apple remote setup", () => { }); if (prepared.status !== "ready") throw new Error("expected ready plan"); - await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toMatchObject({ + await expect( + applyIOSNativeAppleConnection(prepared, { api: harness.api }), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, message: expect.stringContaining("did not pass final verification"), }); @@ -1019,7 +1106,7 @@ describe("native Sign in with Apple remote setup", () => { if (dryRunPrepared.status !== "ready") throw new Error("expected ready plan"); let dryRunError: unknown; try { - await applyIOSNativeAppleConnection(dryRunPrepared, dryRunHarness.api); + await applyIOSNativeAppleConnection(dryRunPrepared, { api: dryRunHarness.api }); } catch (error) { dryRunError = error; } @@ -1033,7 +1120,7 @@ describe("native Sign in with Apple remote setup", () => { if (prepared.status !== "ready") throw new Error("expected ready plan"); let writeError: unknown; try { - await applyIOSNativeAppleConnection(prepared, writeHarness.api); + await applyIOSNativeAppleConnection(prepared, { api: writeHarness.api }); } catch (error) { writeError = error; } 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 207e09bd8..7fe1c7694 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -18,11 +18,16 @@ import { } from "../../../lib/plapi.ts"; import { confirm } from "../../../lib/prompts.ts"; import { withSpinner } from "../../../lib/spinner.ts"; +import type { IOSNativePlatform } from "./types.ts"; const APPLE_CONNECTION_KEY = "connection_oauth_apple"; const CONFIG_VERSION_PATTERN = /^v1_[0-9a-f]{8}$/; const NATIVE_APPLE_PATCH_FIELDS = new Set(["enabled", "authenticatable", "bundle_id"]); +function platformName(platform: IOSNativePlatform | undefined): "iOS" | "macOS" { + return platform === "macos" ? "macOS" : "iOS"; +} + function iosAppleError( message: string, code: ErrorCode = ERROR_CODE.IOS_REMOTE_APPLY_FAILED, @@ -63,6 +68,7 @@ export type IOSNativeApplePlan = { status: "ready" | "satisfied" | "blocked"; applicationId: string; instanceId: string; + platform?: IOSNativePlatform; bundleIdentifier: string; configVersion?: string; connection: "required" | "satisfied" | "blocked"; @@ -112,6 +118,12 @@ export interface IOSNativeAppleAPI { ): Promise>; } +export interface ApplyIOSNativeAppleConnectionOptions { + api?: IOSNativeAppleAPI; + /** Rechecks caller-owned local state immediately before the remote mutation. */ + revalidateLocalPreconditions?: () => Promise; +} + /** GET-only Apple connection API surface used by read-only diagnostics. */ export type IOSNativeAppleReadAPI = Pick< IOSNativeAppleAPI, @@ -121,6 +133,7 @@ export type IOSNativeAppleReadAPI = Pick< export interface AuditIOSNativeAppleHealthOptions { applicationId: string; instanceId: string; + platform?: IOSNativePlatform; bundleIdentifier: string; } @@ -134,6 +147,7 @@ export interface IOSNativeAppleHealthAudit { kind: "clerk-ios-native-apple-health"; applicationId: string; instanceId: string; + platform?: IOSNativePlatform; bundleIdentifier: string; runtime: { status: "required" | "satisfied" | "blocked"; @@ -180,6 +194,7 @@ const defaultPrompts: IOSNativeApplePrompts = { export interface IOSNativeAppleOptions { applicationId: string; instanceId: string; + platform?: IOSNativePlatform; bundleIdentifier: string; /** * The exact selected target's registration is already satisfied or is an @@ -311,7 +326,7 @@ function buildIOSNativeAppleHealthAudit( runtimeBlockers.push( blocker( "bundle-identifier-unavailable", - "Resolve one Bundle ID for the selected iOS target before verifying native Sign in with Apple.", + `Resolve one Bundle ID for the selected ${platformName(options.platform)} target before verifying native Sign in with Apple.`, ), ); } @@ -334,7 +349,7 @@ function buildIOSNativeAppleHealthAudit( runtimeBlockers.push( blocker( "apple-bundle-identifier-conflict", - "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + `The existing Apple connection references a different ${platformName(options.platform)} Bundle ID. clerk init will not replace it.`, ), ); } @@ -399,6 +414,7 @@ function buildIOSNativeAppleHealthAudit( kind: "clerk-ios-native-apple-health", applicationId: options.applicationId, instanceId: options.instanceId, + ...(options.platform ? { platform: options.platform } : {}), bundleIdentifier, runtime: { status: runtimeStatus, @@ -452,7 +468,7 @@ export function buildIOSNativeApplePlan( blockers.push( blocker( "bundle-identifier-unavailable", - "Resolve one Bundle ID for the selected iOS target before enabling native Sign in with Apple.", + `Resolve one Bundle ID for the selected ${platformName(options.platform)} target before enabling native Sign in with Apple.`, ), ); } @@ -460,7 +476,7 @@ export function buildIOSNativeApplePlan( blockers.push( blocker( "native-application-not-ready", - "Verify the exact selected iOS target's Clerk Native Application registration before enabling native Sign in with Apple.", + `Verify the exact selected ${platformName(options.platform)} target's Clerk Native Application registration before enabling native Sign in with Apple.`, ), ); } @@ -502,7 +518,7 @@ export function buildIOSNativeApplePlan( blockers.push( blocker( "apple-bundle-identifier-conflict", - "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + `The existing Apple connection references a different ${platformName(options.platform)} Bundle ID. clerk init will not replace it.`, ), ); } @@ -563,6 +579,7 @@ export function buildIOSNativeApplePlan( status, applicationId: options.applicationId, instanceId: options.instanceId, + ...(options.platform ? { platform: options.platform } : {}), bundleIdentifier, ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), connection, @@ -800,6 +817,7 @@ function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApp return ( current.applicationId === approved.applicationId && current.instanceId === approved.instanceId && + current.platform === approved.platform && bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) ); } @@ -810,8 +828,9 @@ function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeAppl export async function applyIOSNativeAppleConnection( plan: IOSNativeApplePlan, - api: IOSNativeAppleAPI = defaultAPI, + options: ApplyIOSNativeAppleConnectionOptions = {}, ): Promise { + const { api = defaultAPI, revalidateLocalPreconditions } = options; if ( plan.status === "blocked" || !plan.current || @@ -831,6 +850,7 @@ export async function applyIOSNativeAppleConnection( { applicationId: plan.applicationId, instanceId: plan.instanceId, + platform: plan.platform, bundleIdentifier: plan.bundleIdentifier, nativeApplicationReady: true, }, @@ -857,9 +877,13 @@ export async function applyIOSNativeAppleConnection( ERROR_CODE.IOS_SETUP_STALE, ); } + await revalidateLocalPreconditions?.(); + return; + } + if (current.status === "satisfied") { + await revalidateLocalPreconditions?.(); return; } - if (current.status === "satisfied") return; if ( current.status !== "ready" || !current.current || @@ -874,6 +898,7 @@ export async function applyIOSNativeAppleConnection( } await preflightIOSNativeAppleConnection(current, api); + await revalidateLocalPreconditions?.(); try { await withSpinner("Enabling native Sign in with Apple in Clerk...", async () => @@ -892,6 +917,7 @@ export async function applyIOSNativeAppleConnection( { applicationId: plan.applicationId, instanceId: plan.instanceId, + platform: plan.platform, bundleIdentifier: plan.bundleIdentifier, nativeApplicationReady: true, }, diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts index ff7ffd9b2..475281f5f 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -48,6 +48,19 @@ afterEach(async () => { }); describe("buildIOSNativeReadinessAudit", () => { + test("blocks remote identity planning when any configuration platform is unresolved", async () => { + const inspection = await inspectionFor({ + complete: true, + platform: "macos", + releasePlatform: "unresolved", + }); + + expect(buildIOSNativeReadinessAudit(inspection).target).toEqual({ + status: "blocked", + reason: "target-platform-unresolved", + }); + }); + test("reports a redacted selected-target identity and the exact authenticated PLAPI bridge", async () => { const inspection = await inspectionFor({ complete: true }); const selected = inspection.appTargets[0]!; @@ -73,6 +86,7 @@ describe("buildIOSNativeReadinessAudit", () => { projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, targetName: "MyApp", + platform: "ios", bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, appIdPrefix: { status: "resolved", @@ -237,6 +251,7 @@ describe("buildIOSNativeReadinessAudit", () => { root: inspection.root, projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, + platform: "ios", targetName: "MyApp", requiresPublishableKey: false, files: [], 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 852da0c9c..5e3ba0e93 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -1,7 +1,13 @@ import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import { buildIOSSetupPlan } from "./plan.ts"; import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; -import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStepStatus } from "./types.ts"; +import type { + IOSAppTarget, + IOSNativePlatform, + IOSProjectInspectionResult, + IOSSetupStepStatus, +} from "./types.ts"; +import type { IOSPlatformViewsSnapshot } from "./platform-views.ts"; export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { applicationId: "linked-application-id", @@ -58,12 +64,13 @@ export type IOSNativeReadinessTarget = projectPath: string; targetId: string; targetName: string; + platform: IOSNativePlatform; bundleIdentifier: IOSNativeReadinessBundleIdentifier; appIdPrefix: IOSNativeReadinessAppIdPrefix; } | { status: "blocked"; - reason: "target-not-selected" | "selected-target-not-found"; + reason: "target-not-selected" | "selected-target-not-found" | "target-platform-unresolved"; }; export type IOSAssociatedDomainAutomationBlockerCode = @@ -83,7 +90,7 @@ export interface IOSAssociatedDomainAutomationBlocker { export interface IOSAssociatedDomainReadiness { /** The local status from the canonical iOS setup plan. */ - status: IOSSetupStepStatus; + status: IOSSetupStepStatus | "not-applicable"; /** Exact entitlement value derived from redacted publishable-key metadata. */ expectedDomain?: string; /** Existing, inspected XML entitlements files owned by the selected target. */ @@ -108,6 +115,8 @@ export interface IOSNativeReadinessAudit { export interface BuildIOSNativeReadinessAuditOptions { associatedDomainPlan?: IOSAssociatedDomainPlan; + /** Exhaustive cross-platform identity evidence for a multiplatform target. */ + platformViews?: IOSPlatformViewsSnapshot; } function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { @@ -185,10 +194,18 @@ function appIdPrefix(target: IOSAppTarget): IOSNativeReadinessAppIdPrefix { (configuration) => configuration.entitlements?.literalAppIdentifierPrefix === candidates[0], ) ) { - return { status: "resolved", source: "literal-entitlements", value: candidates[0]! }; + return { + status: "resolved", + source: "literal-entitlements", + value: candidates[0]!, + }; } if (candidates.length > 1) { - return { status: "conflicting", source: "literal-entitlements", candidates }; + return { + status: "conflicting", + source: "literal-entitlements", + candidates, + }; } return { status: "missing", source: "literal-entitlements", candidates }; } @@ -201,12 +218,16 @@ function targetIdentity( return { status: "blocked", reason: "target-not-selected" }; } if (!target) return { status: "blocked", reason: "selected-target-not-found" }; + if (!target.platformEvidenceComplete) { + return { status: "blocked", reason: "target-platform-unresolved" }; + } return { status: "selected", projectPath: target.projectPath, targetId: target.id, targetName: target.name, + platform: target.platform, bundleIdentifier: bundleIdentifier(target), appIdPrefix: appIdPrefix(target), }; @@ -217,6 +238,14 @@ function associatedDomainReadiness( target: IOSAppTarget | undefined, associatedDomainPlan: IOSAssociatedDomainPlan | undefined, ): IOSAssociatedDomainReadiness { + if (target?.platform === "macos") { + return { + status: "not-applicable", + files: [], + automatable: false, + blockers: [], + }; + } const plan = buildIOSSetupPlan(inspection, { associatedDomainPlan }); const planStep = plan.steps.find((step) => step.id === "add-associated-domain"); const host = @@ -349,11 +378,28 @@ export function buildIOSNativeReadinessAudit( options: BuildIOSNativeReadinessAuditOptions = {}, ): IOSNativeReadinessAudit { const target = selectedTarget(inspection); + let identity = targetIdentity(inspection, target); + const platformPrefix = options.platformViews?.appIdPrefix; + if ( + platformPrefix && + identity.status === "selected" && + identity.appIdPrefix.status === "missing" + ) { + identity = { + ...identity, + appIdPrefix: { + ...identity.appIdPrefix, + candidates: [ + ...new Set([...(identity.appIdPrefix.candidates ?? []), platformPrefix]), + ].sort(), + }, + }; + } return { schemaVersion: 1, kind: "clerk-ios-native-readiness", root: inspection.root, - target: targetIdentity(inspection, target), + target: identity, associatedDomain: associatedDomainReadiness(inspection, target, options.associatedDomainPlan), remote: { status: "not-inspected", diff --git a/packages/cli-core/src/commands/init/ios/native-remote.test.ts b/packages/cli-core/src/commands/init/ios/native-remote.test.ts index 39c03158e..8f9a36512 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -3,6 +3,7 @@ import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; import { getLogLevel, setLogLevel } from "../../../lib/log.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; +import type { IOSNativePlatform } from "./types.ts"; import { applyIOSNativeRemoteSetup, auditIOSNativeRemoteSetup, @@ -77,6 +78,7 @@ function selectedTarget( appIdPrefixCandidates?: string[]; projectPath?: string; targetId?: string; + platform?: IOSNativePlatform; } = {}, ): IOSNativeReadinessTarget { const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; @@ -85,6 +87,7 @@ function selectedTarget( projectPath: options.projectPath ?? "NativeApp.xcodeproj", targetId: options.targetId ?? "TARGET_NATIVE_APP", targetName: "NativeApp", + platform: options.platform ?? "ios", bundleIdentifier: { status: "resolved", value: options.bundleIdentifier ?? BUNDLE_IDENTIFIER, @@ -108,6 +111,7 @@ function targetSnapshot( root: IOS_ROOT, projectPath: target.projectPath, targetId: target.targetId, + platform: target.platform, bundleIdentifier: target.bundleIdentifier, appIdPrefix: target.appIdPrefix, }; @@ -118,6 +122,7 @@ const approvedTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => ({ projectPath: snapshot.projectPath, targetId: snapshot.targetId, targetName: "NativeApp", + platform: snapshot.platform, bundleIdentifier: snapshot.bundleIdentifier, appIdPrefix: snapshot.appIdPrefix, }); @@ -174,8 +179,14 @@ async function applyRemoteSetup( api: IOSNativeRemoteAPI, targetReader: IOSNativeRemoteTargetReader = approvedTargetReader, registrationRetryStore: IOSNativeRegistrationRetryStore = memoryRegistrationRetryStore().store, + revalidateLocalPreconditions?: () => Promise, ): Promise { - await applyIOSNativeRemoteSetup(approved, api, targetReader, registrationRetryStore); + await applyIOSNativeRemoteSetup(approved, { + api, + targetReader, + registrationRetryStore, + revalidateLocalPreconditions, + }); } function plan(options: { @@ -183,10 +194,12 @@ function plan(options: { registration: "required" | "satisfied"; appIdPrefix?: string; localAppIdPrefix?: string | null; + platform?: IOSNativePlatform; }): IOSNativeRemotePlan { const appIdPrefix = options.appIdPrefix ?? LOCAL_PREFIX; const localTarget = selectedTarget({ appIdPrefix: options.localAppIdPrefix === undefined ? appIdPrefix : options.localAppIdPrefix, + platform: options.platform, }); return { schemaVersion: 1, @@ -197,6 +210,7 @@ function plan(options: { : "ready", applicationId: APPLICATION_ID, instanceId: INSTANCE_ID, + platform: options.platform ?? "ios", localTarget: targetSnapshot(localTarget), bundleIdentifier: BUNDLE_IDENTIFIER, appIdPrefix, @@ -207,7 +221,9 @@ function plan(options: { ? ["Enable the Native API for the linked development instance."] : []), ...(options.registration === "required" - ? [`Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${appIdPrefix}.`] + ? [ + `Register ${options.platform === "macos" ? "macOS" : "iOS"} Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${appIdPrefix}.`, + ] : []), ], blockers: [], @@ -381,6 +397,28 @@ describe("Clerk Native Application remote setup", () => { ).rejects.toBe(transportError); }); + test("plans a macOS target through the existing Apple native application API", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget({ platform: "macos" }), + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result).toMatchObject({ + status: "ready", + platform: "macos", + localTarget: { platform: "macos" }, + registration: "required", + nativeApi: "required", + }); + expect(result.actions).toContain( + `Register macOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${LOCAL_PREFIX}.`, + ); + }); + test("validates Apple identity formats without equating a prefix to the Team ID", () => { expect(validateAppIdPrefix(" LeGaCy1234 ")).toBe("LeGaCy1234"); expect(validateAppIdPrefix("legacy.prefix-value")).toBeUndefined(); @@ -732,6 +770,43 @@ describe("Clerk Native Application remote setup", () => { expect(calls).toEqual([]); }); + test("rejects a macOS target that changes platform before remote access", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied", platform: "macos" }), + api, + async () => selectedTarget({ platform: "ios" }), + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(calls).toEqual([]); + }); + + test("registers a macOS target through the existing native application endpoint", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required", platform: "macos" }), + api, + approvedTargetReader, + ); + + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + expect(captured.err).toContain(`macOS application ${BUNDLE_IDENTIFIER} registered with Clerk`); + }); + test.each([ { name: "Native API was disabled", @@ -1244,6 +1319,71 @@ describe("Clerk Native Application remote setup", () => { expect(calls).not.toContain("PATCH native settings"); }); + test.each([ + { + name: "registration", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + nativeReads: [nativeSettings(true)] as NativeSettings[], + registrationReads: [[]] as IOSApplication[][], + }, + { + name: "Native API", + approved: plan({ nativeApi: "required", registration: "satisfied" }), + nativeReads: [nativeSettings(false)] as NativeSettings[], + registrationReads: [[registration()]] as IOSApplication[][], + }, + ])( + "revalidates caller-owned local state at the $name mutation boundary", + async ({ approved, nativeReads, registrationReads }) => { + const { api, calls } = scriptedAPI({ nativeReads, registrationReads }); + const retry = memoryRegistrationRetryStore(); + let revalidations = 0; + + await expect( + applyRemoteSetup(approved, api, approvedTargetReader, retry.store, async () => { + revalidations += 1; + throw new Error("secondary platform identity changed"); + }), + ).rejects.toThrow("secondary platform identity changed"); + + expect(revalidations).toBe(1); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }, + ); + + test("revalidates caller-owned local state before accepting a remote no-op", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], [registration()]], + }); + let revalidations = 0; + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied" }), + api, + approvedTargetReader, + memoryRegistrationRetryStore().store, + async () => { + revalidations += 1; + throw new Error("secondary platform identity changed"); + }, + ), + ).rejects.toThrow("secondary platform identity changed"); + + expect(revalidations).toBe(1); + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + test("accepts unchanged identity and newly proven evidence matching a confirmed prefix", async () => { const exactRegistration = registration(EXPLICIT_PREFIX); const { api, calls } = scriptedAPI({ @@ -1767,7 +1907,7 @@ describe("Clerk Native Application remote setup", () => { expect(String(thrown)).not.toContain(sensitiveBearer); expect(JSON.stringify(thrown)).not.toContain(sensitiveBearer); expect(captured.err).toContain( - "Could not create the iOS application registration; underlying error details were omitted.", + "Could not create the Apple native application registration; underlying error details were omitted.", ); expect(captured.err).not.toContain(sensitiveBearer); }); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts index 385cf2a7b..b45a46611 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -30,6 +30,7 @@ import type { IOSUnverifiedAppIdPrefixSuggestion, } from "./native-readiness.ts"; import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; +import type { IOSNativePlatform } from "./types.ts"; import { IOSNativeRegistrationRetryLockError, cliStateIOSNativeRegistrationRetryStore, @@ -68,13 +69,18 @@ function retryLockFailureMessage( ? "Clerk Native Application settings were verified, and no further remote changes are required." : "The local setup remains intact, and no registration request was sent."; if (error.status === "busy") { - return `Another Clerk command is updating the iOS registration retry state. ${outcome} Wait for it to finish, then rerun \`clerk init\`.`; + return `Another Clerk command is updating the Apple native application registration retry state. ${outcome} Wait for it to finish, then rerun \`clerk init\`.`; } - return `An interrupted Clerk command left a stale iOS registration retry-state lock. ${outcome} Confirm no other Clerk command is running, then remove only the stale lock directory at \`${error.recoveryPath}\` and rerun \`clerk init\`.`; + return `An interrupted Clerk command left a stale Apple native application registration retry-state lock. ${outcome} Confirm no other Clerk command is running, then remove only the stale lock directory at \`${error.recoveryPath}\` and rerun \`clerk init\`.`; +} + +function platformName(platform: IOSNativePlatform | undefined): "iOS" | "macOS" { + return platform === "macos" ? "macOS" : "iOS"; } export type IOSNativeRemoteBlockerCode = | "target-not-selected" + | "target-platform-unresolved" | "bundle-identifier-unavailable" | "bundle-identifier-invalid" | "app-id-prefix-required" @@ -93,6 +99,7 @@ export interface IOSNativeRemoteTargetSnapshot { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; bundleIdentifier: IOSSelectedNativeReadinessTarget["bundleIdentifier"]; appIdPrefix: IOSSelectedNativeReadinessTarget["appIdPrefix"]; } @@ -103,6 +110,7 @@ export type IOSNativeRemotePlan = { status: "ready" | "satisfied" | "blocked"; applicationId: string; instanceId: string; + platform?: IOSNativePlatform; localTarget?: IOSNativeRemoteTargetSnapshot; bundleIdentifier?: string; appIdPrefix?: string; @@ -128,6 +136,14 @@ export interface IOSNativeRemoteAPI { ): Promise; } +export interface ApplyIOSNativeRemoteSetupOptions { + api?: IOSNativeRemoteAPI; + targetReader?: IOSNativeRemoteTargetReader; + registrationRetryStore?: IOSNativeRegistrationRetryStore; + /** Rechecks caller-owned local state immediately before the first remote mutation. */ + revalidateLocalPreconditions?: () => Promise; +} + /** GET-only Native Application API surface used by read-only diagnostics. */ export type IOSNativeRemoteReadAPI = Pick< IOSNativeRemoteAPI, @@ -241,6 +257,7 @@ function copyTargetSnapshot( root, projectPath: target.projectPath, targetId: target.targetId, + platform: target.platform, bundleIdentifier: target.bundleIdentifier.status === "conflicting" ? { ...target.bundleIdentifier, candidates: [...target.bundleIdentifier.candidates] } @@ -269,18 +286,22 @@ const defaultTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => { }; function localIdentity(target: IOSNativeReadinessTarget): { + platform?: IOSNativePlatform; bundleIdentifier?: string; appIdPrefix?: string; appIdPrefixCandidates: string[]; blockers: IOSNativeRemoteBlocker[]; } { if (target.status !== "selected") { + const platformUnresolved = target.reason === "target-platform-unresolved"; return { appIdPrefixCandidates: [], blockers: [ blocker( - "target-not-selected", - "Select exactly one iOS application target before registering it with Clerk.", + platformUnresolved ? "target-platform-unresolved" : "target-not-selected", + platformUnresolved + ? "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before registering it with Clerk." + : "Select exactly one iOS or macOS application target before registering it with Clerk.", ), ], }; @@ -288,11 +309,12 @@ function localIdentity(target: IOSNativeReadinessTarget): { if (target.bundleIdentifier.status !== "resolved") { return { + platform: target.platform, appIdPrefixCandidates: [], blockers: [ blocker( "bundle-identifier-unavailable", - "Resolve one Bundle ID across every selected-target build configuration before registering the iOS app with Clerk.", + `Resolve one Bundle ID across every selected-target build configuration before registering the ${platformName(target.platform)} app with Clerk.`, ), ], }; @@ -335,6 +357,7 @@ function localIdentity(target: IOSNativeReadinessTarget): { } return { + platform: target.platform, bundleIdentifier: target.bundleIdentifier.value, appIdPrefix: target.appIdPrefix.status === "resolved" && @@ -358,6 +381,7 @@ export function buildIOSNativeRemotePlan(options: { const nativeSettings = validateNativeSettings(options.nativeSettings); const registrations = validateIOSApplications(options.registrations); const identity = localIdentity(options.target); + const platform = identity.platform; const blockers = [...identity.blockers]; const localBundleIdentifier = identity.bundleIdentifier; const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); @@ -456,7 +480,7 @@ export function buildIOSNativeRemotePlan(options: { const actions: string[] = []; if (registration === "required" && appIdPrefix && bundleIdentifier) { actions.push( - `Register iOS Bundle ID ${bundleIdentifier} with Apple App ID Prefix ${appIdPrefix}.`, + `Register ${platformName(platform)} Bundle ID ${bundleIdentifier} with Apple App ID Prefix ${appIdPrefix}.`, ); } if (nativeApi === "required") { @@ -475,6 +499,7 @@ export function buildIOSNativeRemotePlan(options: { status, applicationId: options.applicationId, instanceId: options.instanceId, + ...(platform ? { platform } : {}), localTarget: copyTargetSnapshot(options.root, options.target), bundleIdentifier, appIdPrefix, @@ -522,7 +547,8 @@ async function readIOSNativeRemoteAudit( } /** - * Reads Native API and iOS registration state without prompting, mutating, or + * Reads Native API and Apple native application registration state without + * prompting, mutating, or * wrapping transport errors. The returned plan is a redacted projection of * the two GET responses; raw response objects are not exposed. */ @@ -585,9 +611,9 @@ function appIdPrefixSuggestion( } /** - * A newly created Clerk application cannot already contain the selected iOS - * registration. Stop before application creation when agent mode still needs - * the user to confirm an App ID Prefix. + * A newly created Clerk application cannot already contain the selected Apple + * native application registration. Stop before application creation when + * agent mode still needs the user to confirm an App ID Prefix. */ export function assertIOSAppIdPrefixBeforeApplicationCreation(options: { target: IOSNativeReadinessTarget; @@ -697,7 +723,11 @@ export async function prepareIOSNativeRemoteSetup( } if (plan.status === "satisfied") { - log.info(dim("Clerk Native API and iOS application registration are already configured.")); + log.info( + dim( + `Clerk Native API and ${platformName(plan.platform)} application registration are already configured.`, + ), + ); return plan; } @@ -705,7 +735,7 @@ export async function prepareIOSNativeRemoteSetup( for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); log.info( dim( - "\n Remote changes are additive. clerk init will not update or delete an existing iOS registration.", + "\n Remote changes are additive. clerk init will not update or delete an existing Apple native application registration.", ), ); log.blank(); @@ -732,6 +762,7 @@ async function reconciledPlan( projectPath: "", targetId: "", targetName: "", + platform: plan.platform ?? plan.localTarget?.platform ?? "ios", bundleIdentifier: { status: "resolved", value: plan.bundleIdentifier! }, appIdPrefix: plan.appIdPrefix ? { status: "resolved", source: "literal-entitlements", value: plan.appIdPrefix } @@ -780,6 +811,8 @@ function localTargetStillMatchesApprovedIdentity( current.status !== "selected" || current.projectPath !== approved.projectPath || current.targetId !== approved.targetId || + current.platform !== approved.platform || + plan.platform !== approved.platform || current.bundleIdentifier.status !== "resolved" || !bundleIdentifiersEqual(current.bundleIdentifier.value, plan.bundleIdentifier) ) { @@ -832,6 +865,7 @@ function revalidatedActionSetIsAuthorized( current.status === "blocked" || current.applicationId !== approved.applicationId || current.instanceId !== approved.instanceId || + current.platform !== approved.platform || !bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) || current.appIdPrefix !== approved.appIdPrefix ) { @@ -860,10 +894,14 @@ function registrationRetryIdentity( export async function applyIOSNativeRemoteSetup( plan: IOSNativeRemotePlan, - api: IOSNativeRemoteAPI = defaultAPI, - targetReader: IOSNativeRemoteTargetReader = defaultTargetReader, - registrationRetryStore: IOSNativeRegistrationRetryStore = cliStateIOSNativeRegistrationRetryStore, + options: ApplyIOSNativeRemoteSetupOptions = {}, ): Promise { + const { + api = defaultAPI, + targetReader = defaultTargetReader, + registrationRetryStore = cliStateIOSNativeRegistrationRetryStore, + revalidateLocalPreconditions, + } = options; if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { throw iosRemoteError( "The approved Clerk Native Application plan is incomplete. No remote changes were made; rerun clerk init.", @@ -888,12 +926,14 @@ export async function applyIOSNativeRemoteSetup( ? await registrationRetryStore.getOrCreate(retryIdentity) : await registrationRetryStore.peek(retryIdentity); } catch (error) { - logSuppressedFailure("Could not read or preserve the iOS registration retry state"); + logSuppressedFailure( + "Could not read or preserve the Apple native application registration retry state", + ); if (error instanceof IOSNativeRegistrationRetryLockError) { throw iosRemoteError(retryLockFailureMessage(error, false)); } throw iosRemoteError( - "The iOS application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", + "The Apple native application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", ); } } @@ -921,8 +961,15 @@ export async function applyIOSNativeRemoteSetup( ); } + let localPreconditionsRevalidated = false; + const revalidateBeforeFirstMutation = async (): Promise => { + if (localPreconditionsRevalidated) return; + await revalidateLocalPreconditions?.(); + localPreconditionsRevalidated = true; + }; + // Register first so Native API is never enabled by this command without a - // matching iOS application registration already present. + // matching Apple native application registration already present. if (currentPlan.registration === "required") { if (!retryIdentity) { throw iosRemoteError( @@ -936,15 +983,18 @@ export async function applyIOSNativeRemoteSetup( ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } + await revalidateBeforeFirstMutation(); try { const created = validateIOSApplication( - await withSpinner("Registering the iOS application with Clerk...", async () => - api.createIOSApplication( - plan.applicationId, - plan.instanceId, - { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, - { idempotencyKey: observedRegistrationRetryKey }, - ), + await withSpinner( + `Registering the ${platformName(plan.platform)} application with Clerk...`, + async () => + api.createIOSApplication( + plan.applicationId, + plan.instanceId, + { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, + { idempotencyKey: observedRegistrationRetryKey }, + ), ), ); if ( @@ -952,12 +1002,12 @@ export async function applyIOSNativeRemoteSetup( created.app_id_prefix !== plan.appIdPrefix ) { throw iosRemoteError( - "Clerk returned an unexpected iOS application registration. The local setup remains intact; rerun clerk init to reconcile remote state.", + "Clerk returned an unexpected Apple native application registration. The local setup remains intact; rerun clerk init to reconcile remote state.", ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, ); } } catch (error) { - logSuppressedFailure("Could not create the iOS application registration"); + logSuppressedFailure("Could not create the Apple native application registration"); if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { throw error; } @@ -967,10 +1017,10 @@ export async function applyIOSNativeRemoteSetup( await api.listIOSApplications(plan.applicationId, plan.instanceId), ); } catch (fallbackError) { - logSuppressedFailure("Could not confirm the iOS application registration"); + logSuppressedFailure("Could not confirm the Apple native application registration"); rethrowKnownRemoteError(fallbackError); throw iosRemoteError( - "The iOS application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", + "The Apple native application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", ); } const exact = registrations.some( @@ -981,14 +1031,17 @@ export async function applyIOSNativeRemoteSetup( if (!exact) { rethrowKnownRemoteError(error); throw iosRemoteError( - "The iOS application could not be registered with Clerk. The local setup remains intact; rerun clerk init to retry safely.", + `The ${platformName(plan.platform)} application could not be registered with Clerk. The local setup remains intact; rerun clerk init to retry safely.`, ); } } - log.success(`iOS application ${plan.bundleIdentifier} registered with Clerk`); + log.success( + `${platformName(plan.platform)} application ${plan.bundleIdentifier} registered with Clerk`, + ); } if (currentPlan.nativeApi === "required") { + await revalidateBeforeFirstMutation(); let enableError: unknown; let enabledResponse: unknown; let enableCompleted = false; @@ -1030,13 +1083,13 @@ export async function applyIOSNativeRemoteSetup( logSuppressedFailure("Could not confirm Clerk Native API state"); rethrowKnownRemoteError(fallbackError); throw iosRemoteError( - "Native API enablement could not be confirmed. The local setup and any completed iOS registration remain intact; rerun clerk init.", + "Native API enablement could not be confirmed. The local setup and any completed Apple native application registration remain intact; rerun clerk init.", ); } if (!current.api_enabled) { rethrowKnownRemoteError(enableError); throw iosRemoteError( - "The Native API could not be enabled. The local setup and any completed iOS registration remain intact; rerun clerk init to retry safely.", + "The Native API could not be enabled. The local setup and any completed Apple native application registration remain intact; rerun clerk init to retry safely.", ); } } @@ -1058,10 +1111,13 @@ export async function applyIOSNativeRemoteSetup( } if (finalPlan.status !== "satisfied" || !revalidatedActionSetIsAuthorized(plan, finalPlan)) { throw iosRemoteError( - "Clerk Native Application settings did not pass the final verification. The local iOS setup remains intact; rerun clerk init to reconcile the additive remote steps.", + `Clerk Native Application settings did not pass the final verification. The local ${platformName(plan.platform)} setup remains intact; rerun clerk init to reconcile the additive remote steps.`, ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } + // A no-op still must not claim success for caller-owned local state that + // changed while the authoritative Clerk state was being read. + await revalidateBeforeFirstMutation(); if (retryIdentity && observedRegistrationRetryKey) { try { const cleared = await registrationRetryStore.clear( @@ -1070,11 +1126,13 @@ export async function applyIOSNativeRemoteSetup( ); if (!cleared) { log.debug( - "Preserved a newer iOS registration retry state created after this invocation began.", + "Preserved a newer Apple native application registration retry state created after this invocation began.", ); } } catch (error) { - logSuppressedFailure("Could not clear the verified iOS registration retry state"); + logSuppressedFailure( + "Could not clear the verified Apple native application registration retry state", + ); if (error instanceof IOSNativeRegistrationRetryLockError) { throw iosRemoteError( retryLockFailureMessage(error, true), diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts index efbc3f316..7cc8929f7 100644 --- a/packages/cli-core/src/commands/init/ios/output.ts +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -1,6 +1,7 @@ import type { IOSProjectInspectionResult, IOSSetupPlan, IOSSetupStepStatus } from "./types.ts"; import { buildIOSNativeReadinessAudit, type IOSNativeReadinessAudit } from "./native-readiness.ts"; import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import type { IOSPlatformViewsSnapshot } from "./platform-views.ts"; import { hasSupportedIOSCustomConfigure } from "./products.ts"; const STATUS_MARKER: Record = { @@ -23,6 +24,7 @@ export interface IOSOutputOptions { associatedDomainPlan?: IOSAssociatedDomainPlan; /** Exact readiness audit from the shared local setup proposal. */ nativeReadiness?: IOSNativeReadinessAudit; + platformViews?: IOSPlatformViewsSnapshot; } export function createIOSDryRunOutput( @@ -45,7 +47,25 @@ export function formatIOSSetupPlan( plan: IOSSetupPlan, options: IOSOutputOptions = {}, ): string { - const lines = ["", "iOS setup plan (read-only)", ` Root: ${inspection.root}`]; + const selection = inspection.selection; + const selected = + selection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + const platform = selected + ? selected.platformEvidenceComplete + ? selected.platform + : undefined + : inspection.platform === "ios" || inspection.platform === "macos" + ? inspection.platform + : undefined; + const platformLabel = + platform === "macos" ? "macOS" : platform === "ios" ? "iOS" : "native Apple"; + const readinessLabel = platform == null ? "Native Apple" : `Native ${platformLabel}`; + const lines = ["", `${platformLabel} setup plan (read-only)`, ` Root: ${inspection.root}`]; if (inspection.selection.state === "selected") { lines.push( @@ -60,14 +80,6 @@ export function formatIOSSetupPlan( } } - const selection = inspection.selection; - const selected = - selection.state === "selected" - ? inspection.appTargets.find( - (target) => - target.id === selection.targetId && target.projectPath === selection.projectPath, - ) - : undefined; if (selected) { const bundles = [ ...new Set( @@ -118,17 +130,19 @@ export function formatIOSSetupPlan( const nativeReadiness = options.nativeReadiness ?? buildIOSNativeReadinessAudit(inspection, options); - lines.push("", " Native iOS readiness:"); - lines.push( - ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, - ); - if (!nativeReadiness.associatedDomain.automatable) { - for (const blocker of nativeReadiness.associatedDomain.blockers) { - lines.push(` ${blocker.message}`); + lines.push("", ` ${readinessLabel} readiness:`); + if (platform === "ios") { + lines.push( + ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, + ); + if (!nativeReadiness.associatedDomain.automatable) { + for (const blocker of nativeReadiness.associatedDomain.blockers) { + lines.push(` ${blocker.message}`); + } } } lines.push( - " - Native API and Dashboard iOS registration: not inspected during this local-only dry-run. Regular `clerk init` audits and safely reconciles both on the linked development instance after authentication.", + ` - Native API and Dashboard ${platformLabel} registration: not inspected during this local-only dry-run. Regular \`clerk init\` audits and safely reconciles both on the linked development instance after authentication.`, ); lines.push( diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index e08a676c2..4a7b77541 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -7,7 +7,11 @@ import { planIOSAssociatedDomain } from "./associated-domain.ts"; import { inspectIOSProject } from "./inspect.ts"; import { formatIOSSetupPlan } from "./output.ts"; import { buildIOSSetupPlan } from "./plan.ts"; -import { createIOSFixture } from "./test-helpers.ts"; +import { + addVisionOSDestinationsToFixture, + convertIOSFixtureToMultiplatform, + createIOSFixture, +} from "./test-helpers.ts"; const temporaryDirectories: string[] = []; @@ -24,6 +28,104 @@ afterEach(async () => { }); describe("buildIOSSetupPlan", () => { + test("adds an explicitly labeled macOS network step for a multiplatform target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-multiplatform-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]; + if (!target) throw new Error("Expected an application target"); + target.supportedPlatforms = ["ios", "macos"]; + + const plan = buildIOSSetupPlan(inspection, { + macOSNetworkCapabilityPlan: { + status: "satisfied", + actions: [], + blockers: [], + files: [], + }, + }); + + expect(plan.selection).toMatchObject({ state: "selected", platform: "ios" }); + expect(plan.steps.find((step) => step.id === "enable-macos-network")).toMatchObject({ + title: "Allow outgoing network access for macOS", + status: "satisfied", + }); + expect(plan.steps.map((step) => step.id)).toContain("add-associated-domain"); + }); + + test("does not add a macOS network step to a pure iOS target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-only-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + macOSNetworkCapabilityPlan: { + status: "satisfied", + actions: [], + blockers: [], + files: [], + }, + }); + + expect(inspection.appTargets[0]?.supportedPlatforms).toEqual(["ios"]); + expect(plan.steps.map((step) => step.id)).not.toContain("enable-macos-network"); + }); + + test("uses macOS labels and omits the iOS Associated Domain step", async () => { + const plan = await planFor({ platform: "macos", complete: true }); + + expect(plan.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect(plan.steps.map((step) => step.id)).not.toContain("add-associated-domain"); + expect(plan.steps.find((step) => step.id === "select-target")?.title).toBe( + "Select the macOS application target", + ); + expect(plan.steps.find((step) => step.id === "register-native-application")?.title).toBe( + "Register the macOS app in Clerk Dashboard", + ); + }); + + test("blocks every setup step when a configuration platform is unresolved", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-native-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + platform: "macos", + releasePlatform: "unresolved", + complete: true, + }); + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + const output = formatIOSSetupPlan(inspection, plan); + + expect(plan.selection).toMatchObject({ state: "selected", platform: "macos" }); + expect(plan.status).toBe("blocked"); + expect(plan.steps.every((item) => item.status === "blocked")).toBe(true); + expect(plan.steps[0]?.description).toContain("does not have one proven native platform"); + expect(output).toContain("native Apple setup plan (read-only)"); + expect(output).toContain("Native Apple readiness:"); + expect(output).not.toContain("Associated Domains:"); + }); + + test("explains that a visionOS-bearing target was inspected but is not mutated", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-visionos-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await convertIOSFixtureToMultiplatform(root); + await addVisionOSDestinationsToFixture(root); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + const output = formatIOSSetupPlan(inspection, plan); + + expect(plan.status).toBe("blocked"); + expect(plan.steps.every((item) => item.status === "blocked")).toBe(true); + expect(output).toContain("also ships visionOS"); + expect(output).toContain("Read-only inspection completed"); + expect(output).not.toContain("xros"); + expect(output).not.toContain("Resolve SDKROOT and SUPPORTED_PLATFORMS"); + }); + test("returns stable ordered steps while preserving a custom project key source", async () => { const plan = await planFor({ complete: true }); @@ -834,6 +936,34 @@ import SwiftUI expect(plan.steps.slice(1).every((step) => step.status === "blocked")).toBe(true); }); + test("uses native Apple labels for an ambiguous mixed-platform selection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-native-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { platform: "macos", secondTarget: true }); + const inspection = await inspectIOSProject(root); + const output = formatIOSSetupPlan(inspection, buildIOSSetupPlan(inspection)); + + expect(inspection.selection.state).toBe("ambiguous"); + expect(inspection.platform).toBe("apple-native"); + expect(output).toContain("native Apple setup plan (read-only)"); + expect(output).toContain("Native Apple readiness:"); + expect(output).not.toContain("Associated Domains:"); + }); + + test("uses the inspected macOS platform when the requested target is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { platform: "macos" }); + const inspection = await inspectIOSProject(root, { target: "MissingApp" }); + const output = formatIOSSetupPlan(inspection, buildIOSSetupPlan(inspection)); + + expect(inspection.selection.state).toBe("not-found"); + expect(inspection.platform).toBe("macos"); + expect(output).toContain("macOS setup plan (read-only)"); + expect(output).toContain("Native macOS readiness:"); + expect(output).not.toContain("Associated Domains:"); + }); + test("includes usable choices when the requested target is missing", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index 88db89e62..90634e45f 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -7,13 +7,14 @@ import type { IOSSourceEvidence, IOSValueResolution, } from "./types.ts"; -import { clerkKitUIInstallDecision } from "./products.ts"; +import { clerkKitUIInstallDecision, type ClerkKitUIInstallDecision } from "./products.ts"; import type { IOSDirectConfigPlan } from "./direct-config.ts"; import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; import type { IOSSDKInstallPlan } from "./install-sdk.ts"; import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; +import type { MacOSNetworkCapabilityPlan } from "./macos-network.ts"; const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; @@ -32,6 +33,26 @@ function selectedEvidence(target: IOSAppTarget | undefined): IOSSourceEvidence[] return target ? [{ path: target.projectPath, objectId: target.id }] : []; } +export function selectedTargetPlatformBlockerDescription( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, +): string { + const diagnostic = inspection.diagnostics.find( + (candidate) => + candidate.code === "xcode.unresolved-target-platform" && + candidate.evidence.some( + (evidence) => + evidence.objectId === target.id && + (evidence.path === target.projectPath || + evidence.path.startsWith(`${target.projectPath}/`)), + ), + ); + if (diagnostic) { + return `${diagnostic.message}${diagnostic.remedy ? ` ${diagnostic.remedy}` : ""}`; + } + return `${target.name} was found, but its platform is not proven consistently across every build configuration. Resolve SDKROOT and SUPPORTED_PLATFORMS before Clerk changes the project or remote application.`; +} + function distinctResolvedBundleIdentifiers(target: IOSAppTarget): string[] { const candidatesByIdentity = new Map(); for (const configuration of target.configurations) { @@ -63,6 +84,10 @@ function step( } export interface BuildIOSSetupPlanOptions { + /** Aggregate product choice after inspecting every supported Apple platform view. */ + productDecision?: ClerkKitUIInstallDecision; + /** Fail-closed target-wide blockers shared by init, dry-run, and Doctor. */ + platformCompatibilityBlockers?: readonly string[]; /** Strict SDK/package compatibility from the same planner used by apply. */ sdkInstallPlan?: Pick; /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ @@ -79,6 +104,11 @@ export interface BuildIOSSetupPlanOptions { >; /** Optional native Apple capability requested or already present locally. */ appleEntitlementPlan?: Pick; + /** App Sandbox network access required by a native macOS target. */ + macOSNetworkCapabilityPlan?: Pick< + MacOSNetworkCapabilityPlan, + "status" | "actions" | "blockers" | "files" + >; /** Strict source readiness for the optional prebuilt AuthView scaffold. */ prebuiltAuthPlan?: Pick; /** Whether this invocation explicitly selected the optional AuthView scaffold. */ @@ -91,44 +121,85 @@ export function buildIOSSetupPlan( ): IOSSetupPlan { const target = selectedTarget(inspection); const targetEvidence = selectedEvidence(target); + const platformBlocker = + target && !target.platformEvidenceComplete + ? selectedTargetPlatformBlockerDescription(inspection, target) + : undefined; const steps: IOSSetupStep[] = []; + const platform = + target?.platform ?? + (inspection.platform === "ios" || inspection.platform === "macos" + ? inspection.platform + : undefined); + const selectTargetTitle = + platform === "macos" + ? "Select the macOS application target" + : platform === "ios" + ? "Select the iOS application target" + : "Select the native Apple application target"; steps.push( step( "select-target", - "Select the iOS application target", - target ? "satisfied" : "blocked", - target - ? `Using ${target.name} in ${target.projectPath}.` - : inspection.selection.state === "ambiguous" - ? "More than one iOS app target is eligible. Rerun with --target ; the CLI will not guess." - : inspection.selection.state === "not-found" - ? `The requested target "${inspection.selection.requested}" was not found.${inspection.selection.candidates.length > 0 ? ` Available targets: ${inspection.selection.candidates.join(", ")}.` : ""}` - : "No usable iOS application target was found.", + selectTargetTitle, + target?.platformEvidenceComplete ? "satisfied" : "blocked", + target && !target.platformEvidenceComplete + ? platformBlocker! + : target + ? `Using ${target.name} in ${target.projectPath}.` + : inspection.selection.state === "ambiguous" + ? "More than one native Apple app target is eligible. Rerun with --target ; the CLI will not guess." + : inspection.selection.state === "not-found" + ? `The requested target "${inspection.selection.requested}" was not found.${ + inspection.selection.candidates.length > 0 + ? ` Available targets: ${inspection.selection.candidates.join(", ")}.` + : "" + }` + : "No usable iOS or macOS application target was found.", targetEvidence, ), ); - if (!target) { + if (!target || !target.platformEvidenceComplete) { const blockedSteps: Array<[IOSSetupStep["id"], string]> = [ - ["install-clerk-sdk", "Install Clerk's iOS SDK"], + [ + "install-clerk-sdk", + platform === "macos" + ? "Install Clerk's Swift SDK" + : platform === "ios" + ? "Install Clerk's iOS SDK" + : "Install Clerk's native SDK", + ], ["configure-publishable-key", "Configure Clerk"], ["inject-clerk-environment", "Inject Clerk into SwiftUI"], - ["register-native-application", "Register the native application"], - ["add-associated-domain", "Add the associated domain"], + [ + "register-native-application", + platform === "macos" + ? "Register the macOS application" + : platform === "ios" + ? "Register the iOS application" + : "Register the native application", + ], ["add-authentication-flow", "Add an authentication flow"], ["verify-integration", "Verify the integration"], ]; for (const [id, title] of blockedSteps) { steps.push( - step(id, title, "blocked", "Select an iOS application target before planning this step."), + step( + id, + title, + "blocked", + target + ? "Clerk cannot safely automate this target. See the target-selection step and platform diagnostic above." + : "Select a native Apple application target before planning this step.", + ), ); } return finishPlan(inspection, steps); } const usesClerkKitUI = target.swift.importsClerkKitUI.length > 0; - const productDecision = clerkKitUIInstallDecision(target); + const productDecision = options.productDecision ?? clerkKitUIInstallDecision(target); const includeClerkKitUI = productDecision === "prebuilt" || options.prebuiltAuthSelected === true || @@ -161,16 +232,26 @@ export function buildIOSSetupPlan( steps.push( step( "install-clerk-sdk", - "Install Clerk's iOS SDK for the selected target", + target.platform === "macos" + ? "Install Clerk's Swift SDK for the selected target" + : "Install Clerk's iOS SDK for the selected target", sdkStatus, strictSDKBlocked - ? `The selected Clerk iOS SDK cannot support this approved setup safely: ${strictSDKBlocker ?? "Update the clerk-ios package and rerun the plan."}` + ? `The selected Clerk ${target.platform === "macos" ? "Swift" : "iOS"} SDK cannot support this approved setup safely: ${ + strictSDKBlocker ?? "Update the clerk-ios package and rerun the plan." + }` : productDecision === "unknown" ? `Swift source membership for ${target.name} is incomplete, so the CLI cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the source-membership diagnostics or make the product choice manually.` : sdkStatus === "satisfied" - ? `ClerkKit is linked to ${target.name}${target.packages.clerkKitUI === "linked" ? "; ClerkKitUI is linked too" : ""}.` + ? `ClerkKit is linked to ${target.name}${ + target.packages.clerkKitUI === "linked" ? "; ClerkKitUI is linked too" : "" + }.` : sdkStatus === "review" - ? `ClerkKit${target.packages.clerkKitUI === "linked" ? " and ClerkKitUI are" : " is"} linked to ${target.name}, but the package reference could not be verified as clerk-ios. Confirm the linked products come from Clerk's remote or local package.` + ? `ClerkKit${ + target.packages.clerkKitUI === "linked" ? " and ClerkKitUI are" : " is" + } linked to ${ + target.name + }, but the package reference could not be verified as clerk-ios. Confirm the linked products come from Clerk's remote or local package.` : includeClerkKitUI && target.packages.clerkKitUI !== "linked" ? usesClerkKitUI ? `${target.name} imports ClerkKitUI, but that product is not linked to the target. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package.` @@ -236,7 +317,10 @@ export function buildIOSSetupPlan( publishableKeyBlocked ? "The inline Clerk publishable key is malformed. Replace it before relying on Clerk.configure(...)." : directConfigBlocked - ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` + ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${ + directConfigBlocker ?? + "Review the selected target's @main App initializer and root Scene manually." + }` : configured ? target.swift.configureCalls.length > 1 ? "More than one Clerk.configure(...) call is present. Confirm which call configures the shipping app before continuing." @@ -250,7 +334,10 @@ export function buildIOSSetupPlan( : !target.swift.evidenceComplete ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." : directConfigAutomationReady - ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` + ? `clerk init can add Clerk.configure(publishableKey:) directly to ${ + options.directConfigPlan?.sourcePath ?? + "the single shipping @main App initializer" + } with the selected application's development key. The preview and result keep the value redacted.` : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", target.swift.configureCalls, undefined, @@ -293,14 +380,18 @@ export function buildIOSSetupPlan( injected ? "Clerk.shared is injected into the proven shipping WindowGroup root." : directEnvironmentBlocked - ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's WindowGroup root manually."}` + ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${ + directConfigBlocker ?? "Review the selected target's WindowGroup root manually." + }` : hasUnprovenInjection ? "A Clerk.shared environment modifier exists in target source, but it is not proven on the shipping WindowGroup root. Confirm the mounted root manually." : requiresSwiftUIEnvironment && !provenAppRoot ? "The shipping SwiftUI root could not be proven structurally. Confirm that its mounted root injects Clerk.shared." : target.swift.evidenceComplete && requiresSwiftUIEnvironment ? directEnvironmentAutomationReady - ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` + ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${ + options.directConfigPlan?.sourcePath ?? "the single shipping @main App source" + }.` : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." : requiresSwiftUIEnvironment ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." @@ -328,7 +419,9 @@ export function buildIOSSetupPlan( steps.push( step( "register-native-application", - "Register the iOS app in Clerk Dashboard", + target.platform === "macos" + ? "Register the macOS app in Clerk Dashboard" + : "Register the iOS app in Clerk Dashboard", registrationBlocked ? "blocked" : "review", registrationBlocked ? "A single Bundle ID could not be resolved across build configurations. Make it explicit or consistent before registering the app." @@ -352,7 +445,9 @@ export function buildIOSSetupPlan( ? "Add the native Sign in with Apple entitlement with the exact Default value. After authentication, clerk init will separately audit and enable the matching Clerk Apple connection without requesting hosted/web Apple credentials." : options.appleEntitlementPlan.status === "satisfied" ? "The selected target has the exact native Sign in with Apple entitlement. Regular clerk init will verify the matching Clerk Apple connection after authentication." - : `Native Sign in with Apple needs review: ${options.appleEntitlementPlan.blockers.map((item) => item.message).join(" ")}`; + : `Native Sign in with Apple needs review: ${options.appleEntitlementPlan.blockers + .map((item) => item.message) + .join(" ")}`; steps.push( step( "enable-native-apple", @@ -366,6 +461,34 @@ export function buildIOSSetupPlan( ); } + if (target.supportedPlatforms.includes("macos") && options.macOSNetworkCapabilityPlan) { + const networkPlan = options.macOSNetworkCapabilityPlan; + const networkStatus: IOSSetupStepStatus = + networkPlan.status === "satisfied" + ? "satisfied" + : networkPlan.status === "ready" + ? "required" + : "blocked"; + const networkDescription = + networkPlan.status === "satisfied" + ? "The selected macOS target can make outgoing network connections when App Sandbox is enabled." + : networkPlan.status === "ready" + ? networkPlan.actions.join(" ") + : networkPlan.blockers.map((item) => item.message).join(" ") || + "Outgoing network access could not be verified for the selected macOS target."; + steps.push( + step( + "enable-macos-network", + "Allow outgoing network access for macOS", + networkStatus, + networkDescription, + networkPlan.files.map((file) => ({ path: file.path })), + undefined, + networkPlan.status === "ready", + ), + ); + } + const expectedDomain = inspection.localPublishableKey.state === "valid" ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` @@ -406,13 +529,19 @@ export function buildIOSSetupPlan( associatedDomainPlan?.status === "ready" ? associatedDomainPlan.expectedDomain ? associatedDomainPlan.missingEntitlementsSettings - ? `Create and attach ${associatedDomainPlan.files[0]?.path ?? "an entitlements file"} only to iPhone and iPad builds, then add ${associatedDomainPlan.expectedDomain}. clerk init can apply this safely.` + ? `Create and attach ${ + associatedDomainPlan.files[0]?.path ?? "an entitlements file" + } only to iPhone and iPad builds, then add ${ + associatedDomainPlan.expectedDomain + }. clerk init can apply this safely.` : `Add ${associatedDomainPlan.expectedDomain} to every selected-target entitlements configuration. clerk init can apply the exact existing-file edits safely.` : associatedDomainPlan.missingEntitlementsSettings ? `The selected target has one safe synchronized destination for a new entitlements file. clerk init will create and attach it only to iPhone and iPad builds, then add the linked development application's exact webcredentials host without exposing the publishable key.` : "The existing selected-target entitlements files are safe to edit. clerk init will derive the exact webcredentials host from the linked development application after authentication and add it without exposing the publishable key." : associatedDomainPlan?.status === "blocked" - ? `Automatic Associated Domains setup needs review: ${associatedDomainPlan.blockers.map((blocker) => blocker.message).join(" ")}` + ? `Automatic Associated Domains setup needs review: ${associatedDomainPlan.blockers + .map((blocker) => blocker.message) + .join(" ")}` : expectedDomain && !expectedDomainIsSelectedTargetRuntime ? domainPresent ? `${expectedDomain} matches every inspected entitlements configuration, but the key is only available to copy and is not proven to be the selected target's runtime key. Confirm the runtime key before treating this domain as final.` @@ -424,17 +553,19 @@ export function buildIOSSetupPlan( ? `Some associated-domain values use unresolved build settings. Confirm they expand to ${expectedDomain} in every selected-target configuration.` : `Enable Associated Domains for ${target.name} and add ${expectedDomain} to every selected-target entitlements configuration.` : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; - steps.push( - step( - "add-associated-domain", - "Add Clerk's associated domain", - associatedDomainStatus, - associatedDomainDescription, - target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), - undefined, - associatedDomainPlan?.status === "ready", - ), - ); + if (target.platform === "ios") { + steps.push( + step( + "add-associated-domain", + "Add Clerk's associated domain", + associatedDomainStatus, + associatedDomainDescription, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + undefined, + associatedDomainPlan?.status === "ready", + ), + ); + } const hasAuthFlow = target.swift.authFlowReferences.length > 0; const prebuiltAuthReady = options.prebuiltAuthPlan?.status === "ready" && !strictSDKBlocked; @@ -459,7 +590,11 @@ export function buildIOSSetupPlan( "Add an authentication flow", authFlowStatus, selectedPrebuiltAuthBlocked - ? `The prebuilt AuthView scaffold was requested, but this app is not safe to rewrite automatically: ${strictSDKBlocker ?? options.prebuiltAuthPlan?.blockers.map((blocker) => blocker.message).join(" ") ?? "Review the existing signed-out route and integrate AuthView manually."} Linked AuthView providers are not inspected by this network-free local plan.` + ? `The prebuilt AuthView scaffold was requested, but this app is not safe to rewrite automatically: ${ + strictSDKBlocker ?? + options.prebuiltAuthPlan?.blockers.map((blocker) => blocker.message).join(" ") ?? + "Review the existing signed-out route and integrate AuthView manually." + } Linked AuthView providers are not inspected by this network-free local plan.` : hasAuthFlow || prebuiltAuthSatisfied ? sourceEntryPointIsAmbiguous ? "A Clerk authentication flow is referenced, but multiple @main entry points make the shipping route ambiguous." @@ -468,7 +603,9 @@ export function buildIOSSetupPlan( : "A Clerk authentication UI or sign-in/sign-up flow is referenced in target source." : prebuiltAuthReady ? options.prebuiltAuthSelected - ? `Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to ${options.prebuiltAuthPlan?.sourcePath ?? "the proven placeholder SwiftUI view"}. Linked AuthView providers are not inspected by this network-free local plan; regular clerk init will add or verify the local Sign in with Apple entitlement only if Apple is enabled for the linked instance.` + ? `Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to ${ + options.prebuiltAuthPlan?.sourcePath ?? "the proven placeholder SwiftUI view" + }. Linked AuthView providers are not inspected by this network-free local plan; regular clerk init will add or verify the local Sign in with Apple entitlement only if Apple is enabled for the linked instance.` : `This target's pristine placeholder is eligible for the optional prebuilt AuthView scaffold. Run clerk init with --prebuilt-auth-ui or select it when prompted; existing application UI is never replaced automatically.` : target.swift.evidenceComplete ? productDecision === "core-only" @@ -481,15 +618,21 @@ export function buildIOSSetupPlan( ), ); - const actionable = steps.some((item) => item.status === "required" || item.status === "blocked"); + const platformCompatibilityDetail = options.platformCompatibilityBlockers?.join(" "); + const actionable = + platformCompatibilityDetail != null || + steps.some((item) => item.status === "required" || item.status === "blocked"); steps.push( step( "verify-integration", - "Build and verify sign-in", - "review", - actionable - ? "After completing the required steps, build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled." - : "The local evidence looks complete. Build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled.", + platformCompatibilityDetail + ? "Validate the multiplatform target" + : "Build and verify sign-in", + platformCompatibilityDetail ? "blocked" : "review", + platformCompatibilityDetail ?? + (actionable + ? "After completing the required steps, build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled." + : "The local evidence looks complete. Build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled."), targetEvidence, [{ kind: "documentation", url: QUICKSTART_URL }], ), 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 new file mode 100644 index 000000000..50156b044 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/platform-views.test.ts @@ -0,0 +1,348 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import { + inspectIOSPlatformViews, + iosPlatformViewsHaveAppleEntitlementIntent, + iosPlatformViewsHaveNativeAppleIntent, + iosPlatformViewsSnapshotsEqual, + type IOSPlatformViewInspector, +} from "./platform-views.ts"; +import { + addIOSFixturePlatformFilteredSource, + convertIOSFixtureToMultiplatform, + convertIOSFixtureToPlatformFilteredAppRoots, + createIOSFixture, + IOS_FIXTURE_IDS, +} from "./test-helpers.ts"; + +const roots: string[] = []; + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-platform-views-")); + roots.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, ...options }); + return root; +} + +async function audit(root: string, inspector?: IOSPlatformViewInspector) { + const primary = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + return inspectIOSPlatformViews(primary, inspector); +} + +function blockerCodes(result: Awaited>): string[] { + return result.status === "blocked" ? result.blockers.map((blocker) => blocker.code) : []; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("native Apple platform-view audit", () => { + test("exhaustively snapshots a shared iOS/macOS root and identity without credentials", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const inlineKey = `pk_test_${Buffer.from("platform-audit.clerk.example$").toString("base64")}`; + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + sourcePath, + (await Bun.file(sourcePath).text()).replace( + 'QuickstartLocalSecrets.load().publishableKey ?? ""', + `"${inlineKey}"`, + ), + ); + const calls: Array<{ platform?: string; exhaustive?: boolean; target?: string }> = []; + const inspector: IOSPlatformViewInspector = async (input, options) => { + calls.push({ + platform: options?.platform, + exhaustive: options?.exhaustiveContainerDiscovery, + target: options?.target, + }); + return inspectIOSProject(input, options); + }; + + const result = await audit(root, inspector); + + expect(result.status).toBe("ready"); + if (result.status !== "ready") throw new Error("expected ready platform views"); + expect(calls).toEqual([ + { platform: "ios", exhaustive: true, target: IOS_FIXTURE_IDS.appTarget }, + { platform: "macos", exhaustive: true, target: IOS_FIXTURE_IDS.appTarget }, + ]); + expect(result.snapshot).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-platform-views", + primaryPlatform: "ios", + supportedPlatforms: ["ios", "macos"], + bundleIdentifier: "com.example.myapp", + appIdPrefix: "LEGACY1234", + productDecision: "prebuilt", + requiresClerkKitUI: true, + requiresAuthViewCompatibility: true, + sharedEntryPointPath: "MyApp/MyAppApp.swift", + sharedAppRootPath: "MyApp/MyAppApp.swift", + }); + expect(result.snapshot.platforms.map((view) => view.platform)).toEqual(["ios", "macos"]); + const serialized = JSON.stringify(result.snapshot); + expect(serialized).not.toContain(inlineKey); + expect(JSON.parse(serialized)).toEqual(result.snapshot); + }); + + test("retains Apple entitlement intent from a secondary platform view", async () => { + const root = await fixture(); + await Bun.write( + join(root, "MyApp", "MyApp.mac.entitlements"), + `com.apple.security.app-sandboxcom.apple.security.network.clientcom.apple.developer.applesigninDefault`, + ); + await convertIOSFixtureToMultiplatform(root); + + const result = await audit(root); + + expect(result.status).toBe("ready"); + if (result.status !== "ready") throw new Error("expected ready platform views"); + expect( + result.snapshot.platforms.map((view) => ({ + platform: view.platform, + hasAppleEntitlementIntent: view.hasAppleEntitlementIntent, + })), + ).toEqual([ + { platform: "ios", hasAppleEntitlementIntent: false }, + { platform: "macos", hasAppleEntitlementIntent: true }, + ]); + expect(iosPlatformViewsHaveAppleEntitlementIntent(result.snapshot)).toBe(true); + expect(iosPlatformViewsHaveNativeAppleIntent(result.snapshot)).toBe(true); + }); + + test("ignores an unrelated platform-filtered Swift file in the revalidation snapshot", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const before = await audit(root); + if (before.status !== "ready") throw new Error("expected initial ready platform views"); + + await addIOSFixturePlatformFilteredSource(root, { + platform: "macos", + relativePath: "MacDecoration.swift", + source: + 'import SwiftUI\nstruct MacDecoration: View { var body: some View { Text("Mac") } }\n', + fileReferenceId: "636363636363636363636363", + buildFileId: "646464646464646464646464", + }); + const after = await audit(root); + + expect(after.status).toBe("ready"); + if (after.status !== "ready") throw new Error("expected final ready platform views"); + expect(iosPlatformViewsSnapshotsEqual(before.snapshot, after.snapshot)).toBe(true); + }); + + test("blocks separate platform application roots", async () => { + const root = await fixture({ clerkSDK: "core-only" }); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + iosSource: `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: customKey) } + var body: some Scene { WindowGroup { Text("iOS") } } +} +`, + macOSSource: `import ClerkKitUI +import SwiftUI + +@main +struct MyAppMac: App { + var body: some Scene { WindowGroup { AuthView() } } +} +`, + }); + + const result = await audit(root); + + expect(result.status).toBe("blocked"); + expect(blockerCodes(result)).toContain("divergent-app-root"); + }); + + test("blocks divergent Clerk setup semantics within a shared application root", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + sourcePath, + `${await Bun.file(sourcePath).text()} +#if os(macOS) +extension MyApp { + func configureAgainForMac() { + Clerk.configure(publishableKey: customKey) + } +} +#endif +`, + ); + + const result = await audit(root); + + expect(result.status).toBe("blocked"); + expect(blockerCodes(result)).toContain("divergent-swift-semantics"); + }); + + test("ignores platform-only callback wiring during semantic revalidation", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const before = await audit(root); + if (before.status !== "ready") throw new Error("expected initial ready platform views"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + sourcePath, + `${await Bun.file(sourcePath).text()} +#if os(macOS) +struct MacCallbackView: View { + var body: some View { + Text("Mac").onOpenURL { url in Clerk.shared.handle(url) } + } +} +#endif +`, + ); + + const after = await audit(root); + + expect(after.status).toBe("ready"); + if (after.status !== "ready") throw new Error("expected callback-only difference to pass"); + expect(iosPlatformViewsSnapshotsEqual(before.snapshot, after.snapshot)).toBe(true); + }); + + test("accepts case-only Bundle ID differences but rejects distinct identities", async () => { + const caseOnlyRoot = await fixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(caseOnlyRoot, { + sharedAppRoot: true, + iosBundleIdentifier: "com.Example.MyApp", + macOSBundleIdentifier: "COM.EXAMPLE.MYAPP", + }); + + const caseOnly = await audit(caseOnlyRoot); + + expect(caseOnly.status).toBe("ready"); + if (caseOnly.status !== "ready") throw new Error("expected case-only identity to pass"); + expect(caseOnly.snapshot.bundleIdentifier).toBe("com.example.myapp"); + + const distinctRoot = await fixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(distinctRoot, { + sharedAppRoot: true, + iosBundleIdentifier: "com.example.MyApp.ios", + macOSBundleIdentifier: "com.example.MyApp.macos", + }); + + const distinct = await audit(distinctRoot); + + expect(distinct.status).toBe("blocked"); + expect(blockerCodes(distinct)).toContain("conflicting-bundle-identifier"); + }); + + test("blocks an unresolved secondary platform view", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const inspector: IOSPlatformViewInspector = async (input, options) => { + const inspection = await inspectIOSProject(input, options); + if (options?.platform === "macos") { + const target = inspection.appTargets.find( + (candidate) => candidate.id === IOS_FIXTURE_IDS.appTarget, + ); + if (target) target.platformEvidenceComplete = false; + } + return inspection; + }; + + const result = await audit(root, inspector); + + expect(result.status).toBe("blocked"); + expect(result.status === "blocked" ? result.blockers : []).toContainEqual( + expect.objectContaining({ code: "unresolved-platform", platform: "macos" }), + ); + }); + + test("blocks a secondary view that reports a different supported-platform set", async () => { + const root = await fixture(); + await convertIOSFixtureToMultiplatform(root); + const inspector: IOSPlatformViewInspector = async (input, options) => { + const inspection = await inspectIOSProject(input, options); + if (options?.platform === "macos") { + const target = inspection.appTargets.find( + (candidate) => candidate.id === IOS_FIXTURE_IDS.appTarget, + ); + if (target) target.supportedPlatforms = ["macos"]; + } + return inspection; + }; + + const result = await audit(root, inspector); + + expect(blockerCodes(result)).toContain("supported-platforms-changed"); + }); + + test("rejects conflicting literal App ID Prefix evidence across platform entitlements", async () => { + const root = await fixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + sharedAppRoot: true, + iosAppIdPrefix: "LEGACY1234", + macOSAppIdPrefix: "MODERN5678", + }); + + const result = await audit(root); + + expect(result.status).toBe("blocked"); + expect(blockerCodes(result)).toContain("conflicting-app-id-prefix"); + }); + + test("reads the App ID Prefix from each platform's application identifier entitlement", async () => { + const root = await fixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + sharedAppRoot: true, + iosAppIdPrefix: "LEGACY1234", + macOSAppIdPrefix: "LEGACY1234", + }); + + const [ios, macOS] = await Promise.all([ + inspectIOSProject(root, { target: "MyApp", platform: "ios" }), + inspectIOSProject(root, { target: "MyApp", platform: "macos" }), + ]); + + expect(ios.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + applicationIdentifier: "LEGACY1234.com.example.MyApp", + literalAppIdentifierPrefix: "LEGACY1234", + }); + expect(macOS.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + applicationIdentifier: "LEGACY1234.com.example.MyApp", + literalAppIdentifierPrefix: "LEGACY1234", + }); + }); + + test("does not use the iOS application identifier key as macOS prefix evidence", async () => { + const root = await fixture(); + await convertIOSFixtureToPlatformFilteredAppRoots(root, { + sharedAppRoot: true, + macOSAppIdPrefix: "LEGACY1234", + }); + const entitlementsPath = join(root, "MyApp", "MyApp.mac.entitlements"); + await Bun.write( + entitlementsPath, + (await Bun.file(entitlementsPath).text()).replace( + "com.apple.application-identifier", + "application-identifier", + ), + ); + + const macOS = await inspectIOSProject(root, { target: "MyApp", platform: "macos" }); + + expect( + macOS.appTargets[0]?.configurations[0]?.entitlements?.applicationIdentifier, + ).toBeUndefined(); + expect( + macOS.appTargets[0]?.configurations[0]?.entitlements?.literalAppIdentifierPrefix, + ).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/platform-views.ts b/packages/cli-core/src/commands/init/ios/platform-views.ts new file mode 100644 index 000000000..ddfeb82c4 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/platform-views.ts @@ -0,0 +1,618 @@ +import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; +import { clerkKitUIInstallDecision, type ClerkKitUIInstallDecision } from "./products.ts"; +import type { + IOSAppTarget, + IOSConfigureCallEvidence, + IOSNativePlatform, + IOSProjectInspectionResult, + IOSSourceEvidence, + IOSSwiftInspection, +} from "./types.ts"; + +const NATIVE_PLATFORMS = ["ios", "macos"] as const; + +export type IOSPlatformViewInspector = typeof inspectIOSProject; + +export type IOSPlatformViewBlockerCode = + | "target-not-selected" + | "incomplete-container-discovery" + | "platform-inspection-failed" + | "target-changed" + | "supported-platforms-changed" + | "unresolved-platform" + | "incomplete-swift-evidence" + | "unresolved-bundle-identifier" + | "conflicting-bundle-identifier" + | "conflicting-app-id-prefix" + | "divergent-app-root" + | "divergent-swift-semantics"; + +export interface IOSPlatformViewBlocker { + code: IOSPlatformViewBlockerCode; + platform?: IOSNativePlatform; + message: string; +} + +interface RedactedSourceEvidence { + path: string; + objectId?: string; + keyPath?: string; +} + +interface RedactedConfigureCall extends RedactedSourceEvidence { + publishableKeyWiring: IOSConfigureCallEvidence["publishableKeyWiring"]; + startupBinding: IOSConfigureCallEvidence["startupBinding"]; + inlinePublishableKey?: + | { state: "invalid" } + | { + state: "valid"; + frontendApiHost: string; + instanceType: "development" | "production"; + }; +} + +/** + * Clerk-relevant Swift evidence for one conditioned platform view. Raw source, + * publishable keys, file counts, and unrelated target members are omitted. + */ +export interface IOSPlatformSwiftSnapshot { + evidenceComplete: boolean; + status: IOSSwiftInspection["status"]; + entryPoints: RedactedSourceEvidence[]; + importsClerkKit: RedactedSourceEvidence[]; + importsClerkKitUI: RedactedSourceEvidence[]; + configureCalls: RedactedConfigureCall[]; + appRootEvidence: RedactedSourceEvidence[]; + environmentInjections: RedactedSourceEvidence[]; + rootEnvironmentInjections: RedactedSourceEvidence[]; + environmentConsumers: RedactedSourceEvidence[]; + authViewReferences: RedactedSourceEvidence[]; + authFlowReferences: RedactedSourceEvidence[]; + appleAuthReferences: RedactedSourceEvidence[]; +} + +export interface IOSPlatformTargetViewSnapshot { + platform: IOSNativePlatform; + productDecision: ClerkKitUIInstallDecision; + /** Any exact or malformed Sign in with Apple entitlement evidence for this platform. */ + hasAppleEntitlementIntent: boolean; + swift: IOSPlatformSwiftSnapshot; +} + +/** + * Canonical, secret-free evidence that can be stored with an approved plan and + * compared against a later exhaustive inspection before local or remote writes. + */ +export interface IOSPlatformViewsSnapshot { + schemaVersion: 1; + kind: "clerk-ios-platform-views"; + root: string; + projectPath: string; + targetId: string; + primaryPlatform: IOSNativePlatform; + supportedPlatforms: IOSNativePlatform[]; + /** ASCII case-insensitive Apple Bundle ID identity. */ + bundleIdentifier: string; + /** Exact literal entitlement evidence, when one unambiguous value exists. */ + appIdPrefix?: string; + productDecision: ClerkKitUIInstallDecision; + requiresClerkKitUI: boolean; + requiresAuthViewCompatibility: boolean; + /** Present only when every platform ships the same sole @main source. */ + sharedEntryPointPath: string | null; + /** Present only when every platform proves the same sole SwiftUI app root. */ + sharedAppRootPath: string | null; + platforms: IOSPlatformTargetViewSnapshot[]; +} + +export type IOSPlatformViewsAudit = + | { status: "ready"; snapshot: IOSPlatformViewsSnapshot } + | { status: "blocked"; blockers: IOSPlatformViewBlocker[] }; + +function canonicalPlatforms(platforms: readonly IOSNativePlatform[]): IOSNativePlatform[] { + const values = new Set(platforms); + return NATIVE_PLATFORMS.filter((platform) => values.has(platform)); +} + +function samePlatforms( + left: readonly IOSNativePlatform[], + right: readonly IOSNativePlatform[], +): boolean { + const canonicalLeft = canonicalPlatforms(left); + const canonicalRight = canonicalPlatforms(right); + return ( + canonicalLeft.length === canonicalRight.length && + canonicalLeft.every((platform, index) => platform === canonicalRight[index]) + ); +} + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +function evidenceKey(evidence: RedactedSourceEvidence): string { + return `${evidence.path}\0${evidence.objectId ?? ""}\0${evidence.keyPath ?? ""}`; +} + +function redactEvidence(evidence: IOSSourceEvidence): RedactedSourceEvidence { + return { + path: evidence.path, + ...(evidence.objectId ? { objectId: evidence.objectId } : {}), + ...(evidence.keyPath ? { keyPath: evidence.keyPath } : {}), + }; +} + +function redactedEvidence(values: readonly IOSSourceEvidence[]): RedactedSourceEvidence[] { + return values + .map(redactEvidence) + .sort((left, right) => evidenceKey(left).localeCompare(evidenceKey(right))); +} + +function redactedConfigureCalls( + values: readonly IOSConfigureCallEvidence[], +): RedactedConfigureCall[] { + return values + .map((call): RedactedConfigureCall => ({ + ...redactEvidence(call), + publishableKeyWiring: call.publishableKeyWiring, + startupBinding: call.startupBinding, + ...(call.inlinePublishableKey + ? call.inlinePublishableKey.state === "valid" + ? { + inlinePublishableKey: { + state: "valid", + frontendApiHost: call.inlinePublishableKey.frontendApiHost, + instanceType: call.inlinePublishableKey.instanceType, + }, + } + : { inlinePublishableKey: { state: "invalid" } } + : {}), + })) + .sort((left, right) => { + const evidenceOrder = evidenceKey(left).localeCompare(evidenceKey(right)); + if (evidenceOrder !== 0) return evidenceOrder; + return `${left.publishableKeyWiring}\0${left.startupBinding}`.localeCompare( + `${right.publishableKeyWiring}\0${right.startupBinding}`, + ); + }); +} + +function swiftSnapshot(swift: IOSSwiftInspection): IOSPlatformSwiftSnapshot { + return { + evidenceComplete: swift.evidenceComplete, + status: swift.status, + entryPoints: redactedEvidence(swift.entryPoints), + importsClerkKit: redactedEvidence(swift.importsClerkKit), + importsClerkKitUI: redactedEvidence(swift.importsClerkKitUI), + configureCalls: redactedConfigureCalls(swift.configureCalls), + appRootEvidence: redactedEvidence(swift.appRootEvidence), + environmentInjections: redactedEvidence(swift.environmentInjections), + rootEnvironmentInjections: redactedEvidence(swift.rootEnvironmentInjections), + environmentConsumers: redactedEvidence(swift.environmentConsumers), + authViewReferences: redactedEvidence(swift.authViewReferences), + authFlowReferences: redactedEvidence(swift.authFlowReferences), + appleAuthReferences: redactedEvidence(swift.appleAuthReferences), + }; +} + +interface ClerkSwiftSemanticSignature { + status: IOSSwiftInspection["status"]; + productDecision: ClerkKitUIInstallDecision; + configureCalls: Array< + Pick + >; + hasRootEnvironmentInjection: boolean; + hasEnvironmentConsumer: boolean; + hasAuthView: boolean; + hasAuthenticationFlow: boolean; + hasNativeAppleFlow: boolean; +} + +/** + * Compares only evidence that changes Clerk setup decisions. Evidence paths, + * unrelated target members, and URL/callback listeners deliberately do not + * participate: those listeners are optional integration details rather than + * prerequisites for the basic setup this audit protects. + */ +function clerkSwiftSemanticSignature( + view: IOSPlatformTargetViewSnapshot, +): ClerkSwiftSemanticSignature { + return { + status: view.swift.status, + productDecision: view.productDecision, + configureCalls: view.swift.configureCalls + .map((call) => ({ + publishableKeyWiring: call.publishableKeyWiring, + startupBinding: call.startupBinding, + ...(call.inlinePublishableKey ? { inlinePublishableKey: call.inlinePublishableKey } : {}), + })) + .sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))), + hasRootEnvironmentInjection: view.swift.rootEnvironmentInjections.length > 0, + hasEnvironmentConsumer: view.swift.environmentConsumers.length > 0, + hasAuthView: view.swift.authViewReferences.length > 0, + hasAuthenticationFlow: view.swift.authFlowReferences.length > 0, + hasNativeAppleFlow: view.swift.appleAuthReferences.length > 0, + }; +} + +function evidencePaths(values: readonly RedactedSourceEvidence[]): string[] { + return [...new Set(values.map((value) => value.path))].sort(); +} + +function allViewsHaveSamePaths( + views: readonly IOSPlatformTargetViewSnapshot[], + select: (swift: IOSPlatformSwiftSnapshot) => readonly RedactedSourceEvidence[], +): boolean { + const first = JSON.stringify(evidencePaths(select(views[0]!.swift))); + return views.every((view) => JSON.stringify(evidencePaths(select(view.swift))) === first); +} + +function soleSharedPath( + views: readonly IOSPlatformTargetViewSnapshot[], + select: (swift: IOSPlatformSwiftSnapshot) => readonly RedactedSourceEvidence[], +): string | null { + const paths = views.map((view) => { + const evidence = select(view.swift); + return evidence.length === 1 ? evidence[0]?.path : undefined; + }); + const first = paths[0]; + return first && paths.every((path) => path === first) ? first : null; +} + +function bundleIdentifierIdentity( + target: IOSAppTarget, +): { status: "resolved"; value: string } | { status: "missing" | "conflicting" } { + if ( + target.configurations.length === 0 || + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state !== "resolved", + ) + ) { + return { status: "missing" }; + } + const values = new Set( + target.configurations.map((configuration) => + normalizeBundleIdentifierIdentity( + ( + configuration.bundleIdentifier as Extract< + typeof configuration.bundleIdentifier, + { state: "resolved" } + > + ).value, + ), + ), + ); + return values.size === 1 + ? { status: "resolved", value: [...values][0]! } + : { status: "conflicting" }; +} + +function literalAppIdPrefixes(target: IOSAppTarget): string[] { + return [ + ...new Set( + target.configurations + .map((configuration) => configuration.entitlements?.literalAppIdentifierPrefix) + .filter((value): value is string => value != null), + ), + ].sort(); +} + +function hasAppleEntitlementIntent(target: IOSAppTarget): boolean { + return target.configurations.some( + (configuration) => + configuration.entitlements != null && + configuration.entitlements.signInWithAppleState !== "absent", + ); +} + +function aggregateProductDecision( + views: readonly IOSPlatformTargetViewSnapshot[], +): ClerkKitUIInstallDecision { + if (views.some((view) => view.productDecision === "unknown")) return "unknown"; + return views.some((view) => view.productDecision === "prebuilt") ? "prebuilt" : "core-only"; +} + +/** + * Exhaustively inspects every modeled iOS/macOS view of one selected Xcode + * target and returns a deterministic, redacted semantic snapshot. + */ +export async function inspectIOSPlatformViews( + primaryInspection: IOSProjectInspectionResult, + inspector: IOSPlatformViewInspector = inspectIOSProject, +): Promise { + const primaryTarget = selectedTarget(primaryInspection); + if (!primaryTarget || primaryInspection.selection.state !== "selected") { + return { + status: "blocked", + blockers: [ + { + code: "target-not-selected", + message: + "One native Apple application target must be selected before inspecting platform views.", + }, + ], + }; + } + + const supportedPlatforms = canonicalPlatforms(primaryTarget.supportedPlatforms); + if ( + supportedPlatforms.length === 0 || + !supportedPlatforms.includes(primaryTarget.platform) || + !primaryTarget.platformEvidenceComplete + ) { + return { + status: "blocked", + blockers: [ + { + code: "unresolved-platform", + platform: primaryTarget.platform, + message: "The selected target's native Apple platform support is unresolved.", + }, + ], + }; + } + + const inspections = await Promise.all( + supportedPlatforms.map(async (platform) => { + try { + return { + platform, + inspection: await inspector(primaryInspection.root, { + target: primaryTarget.id, + platform, + exhaustiveContainerDiscovery: true, + }), + } as const; + } catch { + return { platform, inspection: undefined } as const; + } + }), + ); + + const blockers: IOSPlatformViewBlocker[] = []; + const targets: Array<{ platform: IOSNativePlatform; target: IOSAppTarget }> = []; + for (const view of inspections) { + if (!view.inspection) { + blockers.push({ + code: "platform-inspection-failed", + platform: view.platform, + message: `The ${view.platform === "macos" ? "macOS" : "iOS"} target view could not be inspected safely.`, + }); + continue; + } + if (hasIncompleteIOSContainerDiscovery(view.inspection)) { + blockers.push({ + code: "incomplete-container-discovery", + platform: view.platform, + message: `Exhaustive Xcode container discovery was incomplete for the ${view.platform === "macos" ? "macOS" : "iOS"} target view.`, + }); + continue; + } + const selection = view.inspection.selection; + const target = selectedTarget(view.inspection); + if ( + selection.state !== "selected" || + selection.targetId !== primaryTarget.id || + selection.projectPath !== primaryTarget.projectPath || + selection.platform !== view.platform || + !target || + target.id !== primaryTarget.id || + target.projectPath !== primaryTarget.projectPath || + target.platform !== view.platform + ) { + blockers.push({ + code: "target-changed", + platform: view.platform, + message: `The forced ${view.platform === "macos" ? "macOS" : "iOS"} inspection did not select the approved Xcode target.`, + }); + continue; + } + if (!samePlatforms(target.supportedPlatforms, supportedPlatforms)) { + blockers.push({ + code: "supported-platforms-changed", + platform: view.platform, + message: "The selected target's supported native Apple platforms changed between views.", + }); + continue; + } + if (!target.platformEvidenceComplete) { + blockers.push({ + code: "unresolved-platform", + platform: view.platform, + message: `The ${view.platform === "macos" ? "macOS" : "iOS"} target view has unresolved platform evidence.`, + }); + 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 }; + + const bundleIdentities = targets.map(({ platform, target }) => ({ + platform, + identity: bundleIdentifierIdentity(target), + })); + for (const value of bundleIdentities) { + if (value.identity.status === "missing") { + blockers.push({ + code: "unresolved-bundle-identifier", + platform: value.platform, + message: `The ${value.platform === "macos" ? "macOS" : "iOS"} target view does not have one resolved Bundle ID across its build configurations.`, + }); + } else if (value.identity.status === "conflicting") { + blockers.push({ + code: "conflicting-bundle-identifier", + platform: value.platform, + message: `The ${value.platform === "macos" ? "macOS" : "iOS"} target view has conflicting Bundle IDs across its build configurations.`, + }); + } + } + const resolvedBundleIdentities = bundleIdentities.flatMap(({ identity }) => + identity.status === "resolved" ? [identity.value] : [], + ); + if (new Set(resolvedBundleIdentities).size > 1) { + blockers.push({ + code: "conflicting-bundle-identifier", + message: + "The selected target resolves to different Bundle IDs across its supported platforms.", + }); + } + + const prefixes = [ + ...new Set(targets.flatMap(({ target }) => literalAppIdPrefixes(target))), + ].sort(); + if (prefixes.length > 1) { + blockers.push({ + code: "conflicting-app-id-prefix", + message: "The selected target contains conflicting literal Apple App ID Prefix evidence.", + }); + } + if (blockers.length > 0) return { status: "blocked", blockers }; + + const platformSnapshots = targets + .map(({ platform, target }): IOSPlatformTargetViewSnapshot => ({ + platform, + productDecision: clerkKitUIInstallDecision(target), + hasAppleEntitlementIntent: hasAppleEntitlementIntent(target), + swift: swiftSnapshot(target.swift), + })) + .sort( + (left, right) => + NATIVE_PLATFORMS.indexOf(left.platform) - NATIVE_PLATFORMS.indexOf(right.platform), + ); + const productDecision = aggregateProductDecision(platformSnapshots); + if ( + !allViewsHaveSamePaths(platformSnapshots, (swift) => swift.entryPoints) || + !allViewsHaveSamePaths(platformSnapshots, (swift) => swift.appRootEvidence) + ) { + return { + status: "blocked", + blockers: [ + { + code: "divergent-app-root", + message: + "The selected target uses different Swift application roots across its supported platforms.", + }, + ], + }; + } + const semanticSignatures = platformSnapshots.map(clerkSwiftSemanticSignature); + if ( + semanticSignatures.some( + (signature) => JSON.stringify(signature) !== JSON.stringify(semanticSignatures[0]), + ) + ) { + return { + status: "blocked", + blockers: [ + { + code: "divergent-swift-semantics", + message: + "The selected target has different Clerk setup semantics across its supported platforms.", + }, + ], + }; + } + const snapshot: IOSPlatformViewsSnapshot = { + schemaVersion: 1, + kind: "clerk-ios-platform-views", + root: primaryInspection.root, + projectPath: primaryTarget.projectPath, + targetId: primaryTarget.id, + primaryPlatform: primaryTarget.platform, + supportedPlatforms, + bundleIdentifier: resolvedBundleIdentities[0]!, + ...(prefixes[0] ? { appIdPrefix: prefixes[0] } : {}), + productDecision, + requiresClerkKitUI: productDecision === "prebuilt", + requiresAuthViewCompatibility: platformSnapshots.some( + (view) => view.swift.authViewReferences.length > 0, + ), + sharedEntryPointPath: soleSharedPath(platformSnapshots, (swift) => swift.entryPoints), + sharedAppRootPath: soleSharedPath(platformSnapshots, (swift) => swift.appRootEvidence), + platforms: platformSnapshots, + }; + return { status: "ready", snapshot }; +} + +export function iosPlatformViewsSnapshotsEqual( + left: IOSPlatformViewsSnapshot, + right: IOSPlatformViewsSnapshot, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function iosPlatformViewsHaveAppleEntitlementIntent( + snapshot: IOSPlatformViewsSnapshot, +): boolean { + return snapshot.platforms.some((view) => view.hasAppleEntitlementIntent); +} + +export function iosPlatformViewsHaveNativeAppleIntent(snapshot: IOSPlatformViewsSnapshot): boolean { + return snapshot.platforms.some( + (view) => view.hasAppleEntitlementIntent || view.swift.appleAuthReferences.length > 0, + ); +} + +/** + * Rebuilds the exhaustive platform snapshot from the approved target. This is + * intentionally read-only and is used immediately before local or remote + * mutation boundaries. + */ +export async function reinspectIOSPlatformViews( + approved: IOSPlatformViewsSnapshot, + inspector: IOSPlatformViewInspector = inspectIOSProject, +): Promise { + let primary: IOSProjectInspectionResult; + try { + primary = await inspector(approved.root, { + target: approved.targetId, + platform: approved.primaryPlatform, + exhaustiveContainerDiscovery: true, + }); + } catch { + return { + status: "blocked", + blockers: [ + { + code: "platform-inspection-failed", + platform: approved.primaryPlatform, + message: "The approved native Apple target could not be re-inspected safely.", + }, + ], + }; + } + return inspectIOSPlatformViews(primary, inspector); +} + +/** + * Swift setup may intentionally change during a local transaction. Remote + * reconciliation therefore compares only the stable target and Apple identity + * fields, while the pre-write check uses full snapshot equality. + */ +export function iosPlatformViewsIdentityMatches( + approved: IOSPlatformViewsSnapshot, + current: IOSPlatformViewsSnapshot, +): boolean { + return ( + approved.root === current.root && + approved.projectPath === current.projectPath && + approved.targetId === current.targetId && + approved.primaryPlatform === current.primaryPlatform && + samePlatforms(approved.supportedPlatforms, current.supportedPlatforms) && + approved.bundleIdentifier === current.bundleIdentifier && + approved.appIdPrefix === current.appIdPrefix + ); +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts index 4abff2c42..6f09182ed 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -9,7 +9,11 @@ import { planIOSPrebuiltAuth, prepareIOSPrebuiltAuthMutation, } from "./prebuilt-auth.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; +import { + convertIOSFixtureToMultiplatform, + createIOSFixture, + IOS_FIXTURE_IDS, +} from "./test-helpers.ts"; const CONTENT_FILE_ID = "616161616161616161616161"; const CONTENT_BUILD_FILE_ID = "626262626262626262626262"; @@ -85,13 +89,16 @@ afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); -async function createFixture(options: { shared?: boolean; crlf?: boolean } = {}): Promise { +async function createFixture( + options: { shared?: boolean; crlf?: boolean; platform?: "ios" | "macos" } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), "clerk-prebuilt-auth-")); temporaryDirectories.push(root); await createIOSFixture(root, { clerkSDK: true, includeKey: false, secondTarget: options.shared === true, + platform: options.platform, }); const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); const project = parsePbxProject(await readFile(projectPath, "utf8")); @@ -121,11 +128,12 @@ async function createFixture(options: { shared?: boolean; crlf?: boolean } = {}) return root; } -function options(root: string) { +function options(root: string, platform: "ios" | "macos" = "ios") { return { root, projectPath: "MyApp.xcodeproj", targetId: IOS_FIXTURE_IDS.appTarget, + platform, allowDirty: true, } as const; } @@ -148,6 +156,55 @@ async function updateDeploymentTargets( } describe("prebuilt AuthView source setup", () => { + test("supports a pristine macOS 14 SwiftUI app", async () => { + const root = await createFixture({ platform: "macos" }); + + const plan = await planIOSPrebuiltAuth(options(root, "macos")); + + expect(plan).toMatchObject({ + status: "ready", + platform: "macos", + sourcePath: "MyApp/ContentView.swift", + blockers: [], + }); + }); + + test("supports a multiplatform AuthView target at both deployment floors", async () => { + const root = await createFixture(); + await convertIOSFixtureToMultiplatform(root); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan).toMatchObject({ + status: "ready", + platform: "ios", + sourcePath: "MyApp/ContentView.swift", + blockers: [], + }); + }); + + test("blocks a multiplatform AuthView target below the macOS deployment floor", async () => { + const root = await createFixture(); + await convertIOSFixtureToMultiplatform(root); + await updateDeploymentTargets(root, (settings) => { + settings.MACOSX_DEPLOYMENT_TARGET = "13.0"; + }); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan).toMatchObject({ + status: "blocked", + platform: "ios", + blockers: [ + { + code: "incompatible-deployment-target", + message: + "ClerkKitUI's native components require macOS 14.0 or newer. Set MACOSX_DEPLOYMENT_TARGET to 14.0 or newer for every selected-target build configuration, make architecture values consistent, then rerun clerk init.", + }, + ], + }); + }); + test("plans only an exact target-owned untouched SwiftUI placeholder", async () => { const root = await createFixture(); const plan = await planIOSPrebuiltAuth(options(root)); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts index a4e9c74cc..9c2f072d5 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -14,7 +14,7 @@ import { inspectIOSProject, inspectIOSSourceMembership, } from "./inspect.ts"; -import type { IOSBuildConfiguration } from "./types.ts"; +import type { IOSBuildConfiguration, IOSNativePlatform } from "./types.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -22,12 +22,14 @@ export interface IOSPrebuiltAuthPlanOptions { root: string; projectPath: string; targetId: string; + platform?: IOSNativePlatform; allowDirty?: boolean; } export type IOSPrebuiltAuthBlockerCode = | "invalid-selection" | "target-not-found" + | "unresolved-platform" | "generated-project" | "incompatible-deployment-target" | "incomplete-source-membership" @@ -57,6 +59,7 @@ export interface IOSPrebuiltAuthPlan { root: string; projectPath: string; targetId: string; + platform: IOSNativePlatform; allowDirty: boolean; appSourcePath?: string; expectedAppSourceHash?: string; @@ -141,6 +144,7 @@ function makePlan( root, projectPath, targetId: options.targetId, + platform: options.platform ?? "ios", allowDirty: options.allowDirty === true, appSourcePath: details.appSourcePath, expectedAppSourceHash: details.expectedAppSourceHash, @@ -279,25 +283,34 @@ function compactSwift(source: string): string | undefined { return inString ? undefined : result; } -function supportsPrebuiltAuthDeploymentTarget(value: string): boolean { +function supportsPrebuiltAuthDeploymentTarget(value: string, platform: IOSNativePlatform): boolean { const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim()); if (!match) return false; const components = match.slice(1).map((component) => Number(component ?? "0")); if (components.some((component) => !Number.isSafeInteger(component))) return false; - return (components[0] ?? 0) >= 17; + return (components[0] ?? 0) >= (platform === "macos" ? 14 : 17); } -function targetSupportsPrebuiltAuth(configurations: IOSBuildConfiguration[]): boolean { +function targetSupportsPrebuiltAuth( + configurations: IOSBuildConfiguration[], + platform: IOSNativePlatform, +): boolean { return ( configurations.length > 0 && configurations.every( (configuration) => configuration.deploymentTarget.state === "resolved" && - supportsPrebuiltAuthDeploymentTarget(configuration.deploymentTarget.value), + supportsPrebuiltAuthDeploymentTarget(configuration.deploymentTarget.value, platform), ) ); } +function prebuiltAuthDeploymentTargetGuidance(platform: IOSNativePlatform): string { + return platform === "macos" + ? "macOS 14.0 or newer. Set MACOSX_DEPLOYMENT_TARGET to 14.0 or newer for every selected-target build configuration, make architecture values consistent" + : "iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent"; +} + const PRISTINE_CONTENT_VIEW = `import SwiftUI struct ContentView: View { @@ -435,6 +448,7 @@ async function preparePlan(options: IOSPrebuiltAuthPlanOptions): Promise candidate.id === options.targetId && candidate.projectPath === projectPath, + ); + if ( + !inspectedTarget || + !inspectedTarget.platformEvidenceComplete || + inspectedTarget.platform !== platform || + !inspectedTarget.supportedPlatforms.includes(platform) + ) { + return blocked( + options, + root, + projectPath, + "unresolved-platform", + "Resolve SDKROOT and SUPPORTED_PLATFORMS consistently across every selected-target build configuration before changing authentication UI.", + ); + } + platformTarget = inspectedTarget; + } + if (!targetSupportsPrebuiltAuth(platformTarget.configurations, platform)) { + return blocked( + options, + root, + projectPath, + "incompatible-deployment-target", + `ClerkKitUI's native components require ${prebuiltAuthDeploymentTargetGuidance(platform)}, then rerun clerk init.`, + ); + } + } if (!target.swift.evidenceComplete) { return blocked( options, @@ -708,7 +784,10 @@ function readyPrepared( validator: () => Promise, ): PreparedIOSPrebuiltAuthMutation { const prepared = { status: "ready", plan } as PreparedIOSPrebuiltAuthMutation; - Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + Object.defineProperty(prepared, "mutation", { + value: mutation, + enumerable: false, + }); preparedValidators.set(prepared, validator); return prepared; } @@ -744,6 +823,7 @@ export async function prepareIOSPrebuiltAuthMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowDirty: plan.allowDirty, }); if (current.plan.status === "blocked" || !current.sourceSnapshot) { @@ -764,7 +844,9 @@ export async function prepareIOSPrebuiltAuthMutation( if (current.plan.status === "satisfied") return { status: "satisfied", plan: current.plan }; const newline = current.sourceSnapshot.newline; - const generated = `${current.sourceHeader ?? ""}${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; + const generated = `${ + current.sourceHeader ?? "" + }${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; const candidateBytes = new TextEncoder().encode(generated); const boundary = await prepareIOSFileMutationBoundary( plan.root, @@ -784,6 +866,7 @@ export async function prepareIOSPrebuiltAuthMutation( root: plan.root, projectPath: plan.projectPath, targetId: plan.targetId, + platform: plan.platform, allowDirty: true, }); return ( diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index 362289d61..330937391 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -11,6 +11,9 @@ function target(): IOSAppTarget { return { id: "TARGET", name: "MyApp", + platform: "ios", + supportedPlatforms: ["ios"], + platformEvidenceComplete: true, projectPath: "MyApp.xcodeproj", configurations: [], packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, @@ -47,6 +50,7 @@ function inspection(selected: IOSAppTarget): IOSProjectInspectionResult { targetId: selected.id, targetName: selected.name, projectPath: selected.projectPath, + platform: selected.platform, }, localPublishableKey: { state: "missing" }, generatedProject: null, diff --git a/packages/cli-core/src/commands/init/ios/test-helpers.ts b/packages/cli-core/src/commands/init/ios/test-helpers.ts index 78453964b..e5b995191 100644 --- a/packages/cli-core/src/commands/init/ios/test-helpers.ts +++ b/packages/cli-core/src/commands/init/ios/test-helpers.ts @@ -43,6 +43,9 @@ const IDS = { export interface IOSFixtureOptions { complete?: boolean; + platform?: "ios" | "macos"; + /** Override one configuration to exercise cross-configuration platform certainty. */ + releasePlatform?: "ios" | "macos" | "unresolved"; secondTarget?: boolean | "watchos"; conflictingBundle?: boolean; includeKey?: boolean; @@ -51,6 +54,8 @@ export interface IOSFixtureOptions { generated?: "xcodegen" | "tuist"; xcconfig?: boolean; localSecrets?: boolean; + /** Include the canonical native Apple entitlement in the macOS fixture. */ + macOSAppleEntitlement?: boolean; /** Include a fully linked clerk-ios package graph. Defaults to both products. */ clerkSDK?: boolean | "core-only"; } @@ -89,6 +94,27 @@ function secondTargetObjects(platform: "ios" | "watchos"): string { } function pbxproj(options: IOSFixtureOptions): string { + const platform = options.platform ?? "ios"; + const sdkRoot = platform === "macos" ? "macosx" : "iphoneos"; + const supportedPlatforms = platform === "macos" ? "macosx" : "iphoneos iphonesimulator"; + const releasePlatform = options.releasePlatform ?? platform; + const releaseSDKRoot = + releasePlatform === "unresolved" + ? '"$(UNKNOWN_SDKROOT)"' + : releasePlatform === "macos" + ? "macosx" + : "iphoneos"; + const releaseSupportedPlatforms = + releasePlatform === "unresolved" + ? "$(UNKNOWN_PLATFORMS)" + : releasePlatform === "macos" + ? "macosx" + : "iphoneos iphonesimulator"; + const deploymentTargetSetting = + platform === "macos" + ? "MACOSX_DEPLOYMENT_TARGET = 14.0;" + : "IPHONEOS_DEPLOYMENT_TARGET = 17.0;"; + const sandboxSettings = platform === "macos" ? "ENABLE_APP_SANDBOX = YES;" : ""; const includeClerkSDK = options.clerkSDK !== false; const includeClerkKitUI = includeClerkSDK && options.clerkSDK !== "core-only"; const releaseBundle = options.conflictingBundle @@ -158,11 +184,11 @@ function pbxproj(options: IOSFixtureOptions): string { ${includeClerkSDK ? `${IDS.clerkKit} = { isa = XCSwiftPackageProductDependency; package = ${IDS.clerkPackage}; productName = ClerkKit; };` : ""} ${includeClerkKitUI ? `${IDS.clerkKitUI} = { isa = XCSwiftPackageProductDependency; package = ${IDS.clerkPackage}; productName = ClerkKitUI; };` : ""} ${IDS.projectConfigList} = { isa = XCConfigurationList; buildConfigurations = ( ${IDS.projectDebug}, ${IDS.projectRelease}, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - ${IDS.projectDebug} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Debug; }; - ${IDS.projectRelease} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Release; }; + ${IDS.projectDebug} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = ${sdkRoot}; }; name = Debug; }; + ${IDS.projectRelease} = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = ${releaseSDKRoot}; }; name = Release; }; ${IDS.targetConfigList} = { isa = XCConfigurationList; buildConfigurations = ( ${IDS.targetDebug}, ${IDS.targetRelease}, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - ${IDS.targetDebug} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; ${debugIdentitySettings} IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Debug; }; - ${IDS.targetRelease} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { ${releaseEntitlements} ${releaseIdentitySettings} IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Release; }; + ${IDS.targetDebug} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; ${debugIdentitySettings} ${deploymentTargetSetting} ${sandboxSettings} SUPPORTED_PLATFORMS = "${supportedPlatforms}"; }; name = Debug; }; + ${IDS.targetRelease} = { isa = XCBuildConfiguration; ${baseConfigurationReference} buildSettings = { ${releaseEntitlements} ${releaseIdentitySettings} ${deploymentTargetSetting} ${sandboxSettings} SUPPORTED_PLATFORMS = "${releaseSupportedPlatforms}"; }; name = Release; }; ${options.secondTarget ? secondTargetObjects(options.secondTarget === "watchos" ? "watchos" : "ios") : ""} }; rootObject = ${IDS.project}; @@ -235,6 +261,17 @@ const ENTITLEMENTS = ` `; +function macOSEntitlements(includeApple: boolean): string { + return ` + + +com.apple.security.app-sandbox +com.apple.security.network.client +${includeApple ? "com.apple.developer.applesigninDefault" : ""} + +`; +} + export async function createIOSFixture( root: string, options: IOSFixtureOptions = {}, @@ -244,7 +281,12 @@ export async function createIOSFixture( await mkdir(join(root, "MyApp"), { recursive: true }); await Bun.write(join(project, "project.pbxproj"), pbxproj(options)); await Bun.write(join(root, "MyApp", "MyAppApp.swift"), swiftSource(options.complete === true)); - await Bun.write(join(root, "MyApp", "MyApp.entitlements"), ENTITLEMENTS); + await Bun.write( + join(root, "MyApp", "MyApp.entitlements"), + options.platform === "macos" + ? macOSEntitlements(options.macOSAppleEntitlement !== false) + : ENTITLEMENTS, + ); if (options.secondTarget) { const platform = options.secondTarget === "watchos" ? "watchos" : "ios"; const directoryName = platform === "watchos" ? "WatchApp" : "AdminApp"; @@ -290,9 +332,7 @@ export async function createIOSFixture( } /** Converts the classic fixture into the modern synchronized-root shape used by new Xcode apps. */ -export async function convertIOSFixtureToSynchronizedMissingEntitlements( - root: string, -): Promise { +export async function convertIOSFixtureToSynchronizedRoot(root: string): Promise { const synchronizedRootId = "515151515151515151515151"; const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); const project = parsePbxProject(await readFile(projectPath, "utf8")); @@ -309,6 +349,16 @@ export async function convertIOSFixtureToSynchronizedMissingEntitlements( }; objects[IDS.appTarget]!.fileSystemSynchronizedGroups = [synchronizedRootId]; delete objects[IDS.entitlementsFile]; + await writeFile(projectPath, buildPbxProject(project)); +} + +export async function convertIOSFixtureToSynchronizedMissingEntitlements( + root: string, +): Promise { + await convertIOSFixtureToSynchronizedRoot(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; for (const id of [IDS.targetDebug, IDS.targetRelease]) { const settings = objects[id]!.buildSettings as Record; delete settings.CODE_SIGN_ENTITLEMENTS; @@ -318,6 +368,161 @@ export async function convertIOSFixtureToSynchronizedMissingEntitlements( await rm(join(root, "MyApp", "MyApp.entitlements"), { force: true }); } +/** Converts the selected fixture target into Xcode's common single-target iOS + macOS shape. */ +export async function convertIOSFixtureToMultiplatform(root: string): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + const macOSEntitlementsExists = await Bun.file( + join(root, "MyApp", "MyApp.mac.entitlements"), + ).exists(); + + for (const id of [IDS.projectDebug, IDS.projectRelease]) { + const settings = objects[id]!.buildSettings as Record; + settings.SDKROOT = "auto"; + } + + for (const id of [IDS.targetDebug, IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + const existingEntitlements = settings.CODE_SIGN_ENTITLEMENTS; + delete settings.CODE_SIGN_ENTITLEMENTS; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + settings.IPHONEOS_DEPLOYMENT_TARGET = "17.0"; + settings.MACOSX_DEPLOYMENT_TARGET = "14.0"; + settings.ENABLE_APP_SANDBOX = "YES"; + if (typeof existingEntitlements === "string" && existingEntitlements.length > 0) { + settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"] = existingEntitlements; + settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"] = existingEntitlements; + settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"] = "MyApp/MyApp.mac.entitlements"; + } + if (!macOSEntitlementsExists) delete settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]; + } + + await writeFile(projectPath, buildPbxProject(project)); +} + +/** Adds the destinations used by Xcode's standard visionOS-capable Multiplatform template. */ +export async function addVisionOSDestinationsToFixture(root: string): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const id of [IDS.targetDebug, IDS.targetRelease]) { + const settings = objects[id]!.buildSettings as Record; + settings.SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + settings.XROS_DEPLOYMENT_TARGET = "2.0"; + } + await writeFile(projectPath, buildPbxProject(project)); +} + +export interface IOSPlatformFilteredSourceFixture { + platform: "ios" | "macos"; + relativePath: string; + source: string; + fileReferenceId: string; + buildFileId: string; +} + +/** Adds one classic-group Swift member that is compiled for only one Apple platform. */ +export async function addIOSFixturePlatformFilteredSource( + root: string, + fixture: IOSPlatformFilteredSourceFixture, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + objects[fixture.fileReferenceId] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: fixture.relativePath, + sourceTree: "", + }; + objects[fixture.buildFileId] = { + isa: "PBXBuildFile", + fileRef: fixture.fileReferenceId, + platformFilter: fixture.platform, + }; + (objects[IDS.appGroup]!.children as string[]).push(fixture.fileReferenceId); + (objects[IDS.sourcesPhase]!.files as string[]).push(fixture.buildFileId); + await writeFile(projectPath, buildPbxProject(project)); + await writeFile(join(root, "MyApp", fixture.relativePath), fixture.source); +} + +export interface IOSPlatformFilteredAppRootsFixture { + sharedAppRoot?: boolean; + iosSource?: string; + macOSSource?: string; + iosBundleIdentifier?: string; + macOSBundleIdentifier?: string; + iosAppIdPrefix?: string; + macOSAppIdPrefix?: string; +} + +/** + * Converts one fixture target into an iOS/macOS target with distinct, filtered + * @main sources and optionally conditioned Bundle IDs/App ID Prefix evidence. + */ +export async function convertIOSFixtureToPlatformFilteredAppRoots( + root: string, + fixture: IOSPlatformFilteredAppRootsFixture = {}, +): Promise { + await convertIOSFixtureToMultiplatform(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + if (!fixture.sharedAppRoot) objects[IDS.sourceBuildFile]!.platformFilter = "ios"; + for (const configurationId of [IDS.targetDebug, IDS.targetRelease]) { + const settings = objects[configurationId]!.buildSettings as Record; + delete settings.PRODUCT_BUNDLE_IDENTIFIER; + settings["PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]"] = + fixture.iosBundleIdentifier ?? "com.example.MyApp"; + settings["PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]"] = + fixture.iosBundleIdentifier ?? "com.example.MyApp"; + settings["PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]"] = + fixture.macOSBundleIdentifier ?? "com.example.MyApp"; + if (fixture.macOSAppIdPrefix) { + settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"] = "MyApp/MyApp.mac.entitlements"; + } + } + await writeFile(projectPath, buildPbxProject(project)); + + if (!fixture.sharedAppRoot) { + await addIOSFixturePlatformFilteredSource(root, { + platform: "macos", + relativePath: "MyAppMacApp.swift", + source: fixture.macOSSource ?? swiftSource(false).replaceAll("MyApp", "MyAppMac"), + fileReferenceId: "616161616161616161616161", + buildFileId: "626262626262626262626262", + }); + } + if (fixture.iosSource) { + await writeFile(join(root, "MyApp", "MyAppApp.swift"), fixture.iosSource); + } + if (fixture.iosAppIdPrefix) { + const path = join(root, "MyApp", "MyApp.entitlements"); + const bundleIdentifier = fixture.iosBundleIdentifier ?? "com.example.MyApp"; + await writeFile( + path, + (await readFile(path, "utf8")).replace( + /application-identifier<\/key>[^<]*<\/string>/, + `application-identifier${fixture.iosAppIdPrefix}.${bundleIdentifier}`, + ), + ); + } + if (fixture.macOSAppIdPrefix) { + const bundleIdentifier = fixture.macOSBundleIdentifier ?? "com.example.MyApp"; + await writeFile( + join(root, "MyApp", "MyApp.mac.entitlements"), + ` + +com.apple.application-identifier${fixture.macOSAppIdPrefix}.${bundleIdentifier} +com.apple.security.app-sandbox +com.apple.security.network.client + +`, + ); + } +} + async function digestEntry(root: string, path: string): Promise { const info = await lstat(path); const relativePath = relative(root, path).split("\\").join("/") || "."; diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 3f248f513..d6ea6f41e 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -1,5 +1,8 @@ export type IOSDiagnosticSeverity = "info" | "warning" | "error"; +/** The Apple platform selected for Clerk automation on an application target. */ +export type IOSNativePlatform = "ios" | "macos"; + export interface IOSSourceEvidence { /** Project-root-relative path. */ path: string; @@ -16,6 +19,7 @@ export interface IOSDiagnostic { | "xcode.no-ios-app-target" | "xcode.ambiguous-app-target" | "xcode.target-not-found" + | "xcode.unresolved-target-platform" | "xcode.unresolved-build-setting" | "xcode.conflicting-build-setting" | "xcode.missing-entitlements" @@ -67,6 +71,10 @@ export interface IOSBuildConfiguration { developmentTeam: IOSValueResolution; entitlementsPath: IOSValueResolution; deploymentTarget: IOSValueResolution; + /** macOS-only sandbox build setting, omitted for iOS targets. */ + appSandbox?: IOSValueResolution; + /** macOS-only outgoing-network build setting, omitted for iOS targets. */ + outgoingNetworkConnections?: IOSValueResolution; entitlements?: IOSEntitlementsInspection; } @@ -141,6 +149,15 @@ export interface IOSSwiftInspection { export interface IOSAppTarget { id: string; name: string; + /** + * The platform this CLI run will configure. A multiplatform target that + * includes iOS continues through the iOS path. + */ + platform: IOSNativePlatform; + /** Modeled native platforms declared or inferred across the target's build configurations. */ + supportedPlatforms: IOSNativePlatform[]; + /** False when any build configuration's native platform is unresolved or conflicts. */ + platformEvidenceComplete: boolean; productName?: string; projectPath: string; configurations: IOSBuildConfiguration[]; @@ -163,10 +180,21 @@ export interface IOSWorkspaceInspection { } export type IOSTargetSelection = - | { state: "selected"; targetId: string; targetName: string; projectPath: string } + | { + state: "selected"; + targetId: string; + targetName: string; + projectPath: string; + platform: IOSNativePlatform; + } | { state: "ambiguous"; - candidates: Array<{ targetId: string; targetName: string; projectPath: string }>; + candidates: Array<{ + targetId: string; + targetName: string; + projectPath: string; + platform: IOSNativePlatform; + }>; } | { state: "not-found"; requested: string; candidates: string[] } | { state: "none" }; @@ -184,7 +212,7 @@ export type IOSLocalPublishableKeyInspection = export interface IOSProjectInspectionResult { schemaVersion: 1; - platform: "ios"; + platform: IOSNativePlatform | "apple-native"; /** Absolute invocation root. Paths nested below it are emitted relatively. */ root: string; workspaces: IOSWorkspaceInspection[]; @@ -203,6 +231,7 @@ export type IOSSetupStepId = | "inject-clerk-environment" | "register-native-application" | "enable-native-apple" + | "enable-macos-network" | "add-associated-domain" | "add-authentication-flow" | "verify-integration"; diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 8ca08aa7c..d3719ff3e 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -33,6 +33,7 @@ export * as iosApplyMod from "../../commands/init/ios/apply.ts"; export * as nativeRemoteMod from "../../commands/init/ios/native-remote.ts"; export * as nativeAppleMod from "../../commands/init/ios/native-apple.ts"; export * as iosDevelopmentKeyMod from "../../commands/init/ios/development-key.ts"; +export * as iosPlatformViewsMod from "../../commands/init/ios/platform-views.ts"; export * as plapiMod from "../../lib/plapi.ts"; export * as fapiMod from "../../lib/fapi.ts"; @@ -55,12 +56,54 @@ import * as iosApplyModule from "../../commands/init/ios/apply.ts"; import * as nativeRemoteModule from "../../commands/init/ios/native-remote.ts"; import * as nativeAppleModule from "../../commands/init/ios/native-apple.ts"; import * as iosDevelopmentKeyModule from "../../commands/init/ios/development-key.ts"; +import * as iosPlatformViewsModule from "../../commands/init/ios/platform-views.ts"; import * as plapiModule from "../../lib/plapi.ts"; import * as fapiModule from "../../lib/fapi.ts"; import { IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, type IOSNativeReadinessAudit, } from "../../commands/init/ios/native-readiness.ts"; +import type { IOSPlatformViewsSnapshot } from "../../commands/init/ios/platform-views.ts"; + +const EMPTY_SWIFT_PLATFORM_SNAPSHOT = { + evidenceComplete: true, + status: "absent" as const, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + appRootEvidence: [], + environmentInjections: [], + rootEnvironmentInjections: [], + environmentConsumers: [], + authViewReferences: [], + authFlowReferences: [], + appleAuthReferences: [], +}; + +export const FAKE_IOS_PLATFORM_VIEWS: IOSPlatformViewsSnapshot = { + schemaVersion: 1, + kind: "clerk-ios-platform-views", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + primaryPlatform: "ios", + supportedPlatforms: ["ios"], + bundleIdentifier: "com.example.myapp", + productDecision: "core-only", + requiresClerkKitUI: false, + requiresAuthViewCompatibility: false, + sharedEntryPointPath: null, + sharedAppRootPath: null, + platforms: [ + { + platform: "ios", + productDecision: "core-only", + hasAppleEntitlementIntent: false, + swift: EMPTY_SWIFT_PLATFORM_SNAPSHOT, + }, + ], +}; export const FAKE_CTX = { cwd: "/tmp/test", @@ -94,6 +137,7 @@ export const FAKE_IOS_NATIVE_READINESS: IOSNativeReadinessAudit = { projectPath: "MyApp.xcodeproj", targetId: "TARGET", targetName: "MyApp", + platform: "ios", bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, appIdPrefix: { status: "resolved", @@ -209,11 +253,18 @@ export function useInitHarness(): InitHarness { instanceId: "ins_test", publishableKey: "pk_test_redacted", }), + spyOn(iosPlatformViewsModule, "reinspectIOSPlatformViews").mockResolvedValue({ + status: "ready", + snapshot: FAKE_IOS_PLATFORM_VIEWS, + }), spyOn(fapiModule, "fetchUserSettings").mockResolvedValue({ social: {} } as never), spyOn(bootstrapModule, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), spyOn(bootstrapModule, "confirmOverwrite").mockResolvedValue(undefined), spyOn(iosApplyModule, "applyIOSLocalSetup").mockResolvedValue({ targetName: "MyApp", + platform: "ios", + supportedPlatforms: ["ios"], + platformViews: FAKE_IOS_PLATFORM_VIEWS, setupPlan: { schemaVersion: 1, kind: "clerk-ios-setup", @@ -224,6 +275,7 @@ export function useInitHarness(): InitHarness { targetId: "TARGET", targetName: "MyApp", projectPath: "MyApp.xcodeproj", + platform: "ios", }, summary: { satisfied: 0, required: 0, review: 0, blocked: 0 }, steps: [], @@ -244,6 +296,7 @@ export function useInitHarness(): InitHarness { status: "satisfied", applicationId: "app_test", instanceId: "ins_test", + platform: "ios", bundleIdentifier: "com.example.MyApp", appIdPrefix: "LEGACY1234", nativeApi: "satisfied", diff --git a/test/e2e/native-init.test.ts b/test/e2e/native-init.test.ts index cd9f0f0ad..fccaf2a67 100644 --- a/test/e2e/native-init.test.ts +++ b/test/e2e/native-init.test.ts @@ -23,7 +23,7 @@ const PLATFORMS = [ instructions: [ "ClerkKit and ClerkKitUI linked to MyApp", "Clerk configured in MyApp/MyAppApp.swift", - "Clerk Native API and iOS application registration verified", + "Clerk Native API and application registration verified", ], expectedGitEntries: ["M MyApp.xcodeproj/project.pbxproj", "M MyApp/MyAppApp.swift"], },