diff --git a/.changeset/ios-native-local-orchestration.md b/.changeset/ios-native-local-orchestration.md new file mode 100644 index 000000000..55774be5e --- /dev/null +++ b/.changeset/ios-native-local-orchestration.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Add safe local setup planning and transactional mutation support for supported native Apple projects. diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts new file mode 100644 index 000000000..00c350d77 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -0,0 +1,403 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, +} from "./associated-domain.ts"; +import { + applyIOSAppleEntitlement, + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, +} from "./apple-entitlement.ts"; +import { applyIOSFileTransaction } from "./file-transaction.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; +const APPLE_KEY = "com.apple.developer.applesignin"; +const HOST = "apple-native.clerk.example"; +const KEY = `pk_test_${Buffer.from(`${HOST}$`).toString("base64")}`; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-apple-entitlement-")); + temporaryDirectories.push(root); + return root; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, options); + return root; +} + +function planOptions(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }; +} + +function appleBlock(value = "Default", newline = "\n"): string { + return [ + `\t${APPLE_KEY}`, + "\t", + `\t\t${value}`, + "\t", + ].join(newline); +} + +async function replaceEntitlements(root: string, body: string, newline = "\n"): Promise { + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = [ + '', + '', + '', + "", + "\t", + body, + "", + "", + "", + ].join(newline); + await writeFile(path, source); + return source; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS Sign in with Apple entitlement setup", () => { + test("adds exactly Default while preserving comments, CRLF newlines, mode, and idempotence", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const original = await replaceEntitlements( + root, + [ + "\tapplication-identifier", + "\tLEGACY1234.com.example.MyApp", + ].join("\r\n"), + "\r\n", + ); + await chmod(path, 0o640); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared Apple mutation"); + expect(prepared.mutations[0]?.boundary.rootPath).toBe(root); + expect(prepared.mutations[0]?.boundary.realParentPath.endsWith("/MyApp")).toBe(true); + expect(JSON.stringify({ plan, prepared })).not.toContain("candidateBytes"); + expect(JSON.stringify({ plan, prepared })).not.toContain(""); + expect(source).toContain("\r\n"); + expect(source.replace(appleBlock("Default", "\r\n"), "")).toContain(original.split("\r\n")[5]!); + expect((await lstat(path)).mode & 0o7777).toBe(0o640); + + const digest = await treeDigest(root); + const rerun = await planIOSAppleEntitlement(planOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSAppleEntitlement(rerun)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("preserves the closing dict indentation without inserting a whitespace-only line", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = ( + await replaceEntitlements(root, "\texisting\n\tvalue") + ).replace("\n", "\n "); + await writeFile(path, source); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + const updated = await readFile(path, "utf8"); + + expect(result.status).toBe("applied"); + expect(updated).toContain(`${appleBlock()}\n `); + expect(updated).not.toContain("\n \n"); + }); + + test("treats only the exact one-element Default array as satisfied", async () => { + const root = await fixture(); + await replaceEntitlements(root, appleBlock()); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("satisfied"); + expect(plan.actions).toEqual([]); + expect((await prepareIOSAppleEntitlementMutation(plan)).status).toBe("satisfied"); + }); + + test("blocks conflicting, malformed, duplicated, and encoded Apple entitlement values", async () => { + const cases = [ + `${APPLE_KEY}PrimaryApp`, + `${APPLE_KEY}DefaultPrimaryApp`, + `${APPLE_KEY}Default`, + `${APPLE_KEY}Default${APPLE_KEY}Default`, + `com.apple.developer.applesigninDefault`, + ]; + for (const body of cases) { + const root = await fixture(); + await replaceEntitlements(root, body); + const before = await treeDigest(root); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toMatch( + /conflicting-apple-entitlement|unsupported-entitlements/, + ); + expect((await applyIOSAppleEntitlement(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("blocks an oversized entitlements file without changing it", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-entitlements"); + expect(result.status).toBe("blocked"); + expect(await readFile(path)).toEqual(oversized); + }); + + test("updates every distinct entitlements variant selected by target configurations", async () => { + const root = await fixture(); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const debugPath = join(root, "MyApp", "MyApp.entitlements"); + const releasePath = join(root, "MyApp", "MyApp-Release.entitlements"); + await writeFile(releasePath, await readFile(debugPath)); + const project = await readFile(projectPath, "utf8"); + const marker = `${IOS_FIXTURE_IDS.targetRelease} = { isa = XCBuildConfiguration;`; + const start = project.indexOf(marker); + const end = project.indexOf("\n ", start + marker.length); + await writeFile( + projectPath, + `${project.slice(0, start)}${project + .slice(start, end) + .replace( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp-Release.entitlements;", + )}${project.slice(end)}`, + ); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.files.map((file) => file.path)).toEqual([ + "MyApp/MyApp-Release.entitlements", + "MyApp/MyApp.entitlements", + ]); + expect(result.status).toBe("applied"); + for (const file of plan.files) { + expect(await readFile(join(root, file.path), "utf8")).toContain(APPLE_KEY); + } + }); + + test("inherits exact-target, generated-project, mixed-path, and shared-file safety blockers", async () => { + const invalid = await fixture(); + expect( + (await planIOSAppleEntitlement({ ...planOptions(invalid), targetId: "missing" })).blockers[0] + ?.code, + ).toBe("invalid-selection"); + + const generated = await fixture({ generated: "tuist" }); + expect((await planIOSAppleEntitlement(planOptions(generated))).blockers[0]?.code).toBe( + "generated-project", + ); + + const mixed = await fixture({ releaseEntitlements: false }); + expect((await planIOSAppleEntitlement(planOptions(mixed))).blockers[0]?.code).toBe( + "mixed-entitlements", + ); + + const shared = await fixture({ secondTarget: true }); + const projectPath = join(shared, "MyApp.xcodeproj", "project.pbxproj"); + let project = await readFile(projectPath, "utf8"); + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + const marker = `${id} = { isa = XCBuildConfiguration; buildSettings = { `; + project = project.replace( + marker, + `${marker}CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; `, + ); + } + await writeFile(projectPath, project); + expect((await planIOSAppleEntitlement(planOptions(shared))).blockers[0]?.code).toBe( + "shared-entitlements", + ); + + const outside = await fixture(); + const unsafe = await fixture(); + const unsafePath = join(unsafe, "MyApp", "MyApp.entitlements"); + await rm(unsafePath); + await symlink(join(outside, "MyApp", "MyApp.entitlements"), unsafePath); + expect((await planIOSAppleEntitlement(planOptions(unsafe))).blockers[0]?.code).toBe( + "unsafe-entitlements", + ); + }); + + test("returns stale without touching a post-preview user edit", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const plan = await planIOSAppleEntitlement(planOptions(root)); + await writeFile(path, "newer user bytes\n"); + + const result = await applyIOSAppleEntitlement(plan); + + expect(result.status).toBe("stale"); + expect(await readFile(path, "utf8")).toBe("newer user bytes\n"); + }); + + test("returns stale without touching an entitlements file that grows beyond the limit", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const plan = await planIOSAppleEntitlement(planOptions(root)); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const prepared = await prepareIOSAppleEntitlementMutation(plan); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.status).toBe("ready"); + expect(prepared.status).toBe("stale"); + expect(result.status).toBe("stale"); + expect(await readFile(path)).toEqual(oversized); + }); + + test("creates and attaches a missing synchronized-root entitlements file", async () => { + const root = await fixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const path = join(root, "MyApp", "MyApp.entitlements"); + + const plan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const prepared = await prepareIOSAppleEntitlementMutation(plan); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], + missingEntitlementsSettings: { status: "ready" }, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared Apple create mutation"); + const createMutation = prepared.mutations.find((mutation) => "kind" in mutation); + expect(createMutation?.boundary.rootPath).toBe(root); + expect(createMutation?.boundary.realParentPath.endsWith("/MyApp")).toBe(true); + expect(result.status).toBe("applied"); + expect(await readFile(path, "utf8")).toContain(appleBlock()); + expect((await lstat(path)).mode & 0o7777).toBe(0o644); + expect((await planIOSAppleEntitlement(planOptions(root))).status).toBe("satisfied"); + }); + + test("composes with the Associated Domains create and PBX candidates", async () => { + const root = await fixture({ includeKey: false }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${KEY}") } + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + allowMissingEntitlementsCreation: true, + }); + const applePlan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + expect(prepared.consumedBaseMutationPaths).toEqual( + associated.mutations.map((mutation) => mutation.path).sort(), + ); + const associatedCreate = associated.mutations.find((mutation) => "kind" in mutation); + const appleCreate = prepared.mutations.find((mutation) => "kind" in mutation); + expect(appleCreate?.boundary).toEqual(associatedCreate?.boundary); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + + const result = await applyIOSFileTransaction(prepared.mutations, [ + () => validatePreparedIOSAppleEntitlement(prepared), + () => validatePreparedIOSAssociatedDomain(associated), + ]); + const source = await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toContain(APPLE_KEY); + expect(source).toContain(`webcredentials:${HOST}`); + }); + + test("composes with an existing entitlements candidate and rolls the aggregate write back", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", "applinks:keep.test"), + ); + const before = await readFile(path); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + const applePlan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + + const result = await applyIOSFileTransaction(prepared.mutations, [() => false]); + + expect(result.status).toBe("rolled-back"); + expect(await readFile(path)).toEqual(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts new file mode 100644 index 000000000..b6216e060 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -0,0 +1,731 @@ +import { + bytesWithOptionalBOM, + newEntitlementsBytes, + appendEntitlementsEntry, + entitlementKeyStructure, + decodeEntitlementsXML, +} from "./entitlements-xml.ts"; +import { lstat } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { + planIOSAssociatedDomain, + type IOSAssociatedDomainBlockerCode, +} from "./associated-domain.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; + +const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; +const APPLE_SIGN_IN_VALUE = "Default"; +const MAX_ENTITLEMENTS_BYTES = 1_000_000; + +export type IOSAppleEntitlementBlockerCode = + | IOSAssociatedDomainBlockerCode + | "conflicting-apple-entitlement" + | "invalid-plan"; + +export interface IOSAppleEntitlementBlocker { + code: IOSAppleEntitlementBlockerCode; + message: string; +} + +export interface IOSAppleEntitlementPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface IOSAppleEntitlementPlan { + schemaVersion: 1; + kind: "clerk-ios-sign-in-with-apple-entitlement"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + targetName?: string; + files: IOSAppleEntitlementPlanFile[]; + /** PBX settings needed only when the target has no entitlements file yet. */ + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + actions: string[]; + blockers: IOSAppleEntitlementBlocker[]; +} + +export interface IOSAppleEntitlementPlanOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; + /** Allows the strict synchronized-root planner to create and attach a new file. */ + allowMissingEntitlementsCreation?: boolean; +} + +export interface IOSAppleEntitlementPrepareOptions { + /** + * Previously prepared file candidates to compose with. Candidate bytes remain + * private and must never be serialized into output or telemetry. + */ + baseMutations?: readonly IOSFileMutation[]; +} + +export type PreparedIOSAppleEntitlementMutation = + | { status: "satisfied"; plan: IOSAppleEntitlementPlan } + | { status: "blocked"; plan: IOSAppleEntitlementPlan } + | { status: "stale"; plan: IOSAppleEntitlementPlan } + | { + status: "ready"; + plan: IOSAppleEntitlementPlan; + /** @internal Candidate bytes must never be serialized into output or telemetry. */ + mutations: IOSFileMutation[]; + /** Absolute paths whose caller-supplied candidates were semantically composed. */ + consumedBaseMutationPaths: string[]; + }; + +export interface IOSAppleEntitlementApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSAppleEntitlementPlan; +} + +interface EntitlementsDocument { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + appleState: "absent" | "exact"; +} + +type EntitlementsInspection = + | { status: "safe"; document: EntitlementsDocument } + | { status: "blocked"; blocker: IOSAppleEntitlementBlocker }; + +function blocker( + code: IOSAppleEntitlementBlockerCode, + message: string, +): IOSAppleEntitlementBlocker { + return { code, message }; +} + +function planBase(options: IOSAppleEntitlementPlanOptions) { + return { + schemaVersion: 1 as const, + kind: "clerk-ios-sign-in-with-apple-entitlement" as const, + root: resolve(options.root), + projectPath: options.projectPath.replaceAll("\\", "/"), + targetId: options.targetId, + }; +} + +function blockedPlan( + options: IOSAppleEntitlementPlanOptions, + blockers: IOSAppleEntitlementBlocker[], + targetName?: string, +): IOSAppleEntitlementPlan { + return { + ...planBase(options), + status: "blocked", + ...(targetName ? { targetName } : {}), + files: [], + actions: [], + blockers, + }; +} + +function blockPrepared( + plan: IOSAppleEntitlementPlan, + code: IOSAppleEntitlementBlockerCode, + message: string, +): Extract { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [blocker(code, message)], + }, + }; +} + +function inspectEntitlementsBytes( + root: string, + absolutePath: string, + bytes: Uint8Array, + mode: number, +): EntitlementsInspection { + const relativePath = relativeIOSPath(root, absolutePath); + try { + if (bytes.byteLength > MAX_ENTITLEMENTS_BYTES) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} must be an XML plist no larger than 1 MB.`, + ), + }; + } + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} is a binary plist. Save it as XML before automatic setup.`, + ), + }; + } + const { source, bom, values: parsed } = decodeEntitlementsXML(bytes); + const rawValue = parsed[APPLE_SIGN_IN_KEY]; + const structure = entitlementKeyStructure(source, APPLE_SIGN_IN_KEY); + if ( + !structure.safelyDecoded || + structure.literalCount > 1 || + structure.semanticCount > 1 || + (rawValue !== undefined && (structure.literalCount !== 1 || structure.semanticCount !== 1)) || + (rawValue === undefined && (structure.literalCount !== 0 || structure.semanticCount !== 0)) + ) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} does not contain one safely editable literal Sign in with Apple key.`, + ), + }; + } + if (rawValue !== undefined) { + if ( + !Array.isArray(rawValue) || + rawValue.length !== 1 || + rawValue[0] !== APPLE_SIGN_IN_VALUE + ) { + return { + status: "blocked", + blocker: blocker( + "conflicting-apple-entitlement", + `${relativePath} has a conflicting Sign in with Apple entitlement; expected exactly ["Default"].`, + ), + }; + } + } + return { + status: "safe", + document: { + absolutePath, + relativePath, + bytes, + hash: hashIOSFileBytes(bytes), + mode, + source, + bom, + appleState: rawValue === undefined ? "absent" : "exact", + }, + }; + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativePath} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +async function inspectEntitlementsFile( + root: string, + absolutePath: string, +): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + status: "blocked", + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "not-regular" || file.status === "too-large") { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + if (file.status !== "ok") { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } + return inspectEntitlementsBytes(root, absolutePath, file.bytes, file.mode); +} + +function addAppleEntitlementToXML(source: string): string | undefined { + if (entitlementKeyStructure(source, APPLE_SIGN_IN_KEY).semanticCount !== 0) return undefined; + return appendEntitlementsEntry(source, appleEntitlementLines()); +} + +function appleEntitlementLines(): string[] { + return [ + `${APPLE_SIGN_IN_KEY}`, + "", + `\t${APPLE_SIGN_IN_VALUE}`, + "", + ]; +} + +function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { + return "kind" in mutation && mutation.kind === "create"; +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +function validBaseMutation(mutation: IOSFileMutation): boolean { + return ( + Number.isInteger(mutation.mode) && + mutation.mode >= 0 && + mutation.mode <= 0o7777 && + hashIOSFileBytes(mutation.candidateBytes) === mutation.candidateHash && + (isCreateMutation(mutation) || + hashIOSFileBytes(mutation.originalBytes) === mutation.originalHash) + ); +} + +function preparedWithHiddenMutations( + plan: IOSAppleEntitlementPlan, + mutations: IOSFileMutation[], + consumedBaseMutationPaths: string[], +): Extract { + const result = { + status: "ready" as const, + plan, + consumedBaseMutationPaths: [...consumedBaseMutationPaths].sort(), + } as Extract; + Object.defineProperty(result, "mutations", { + value: mutations, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +function samePlanFiles( + left: readonly IOSAppleEntitlementPlanFile[], + right: readonly IOSAppleEntitlementPlanFile[], +): boolean { + return ( + left.length === right.length && + left.every( + (file, index) => + file.path === right[index]?.path && + file.operation === right[index]?.operation && + file.expectedHash === right[index]?.expectedHash, + ) + ); +} + +function candidateWithApple(root: string, document: EntitlementsDocument): Uint8Array | undefined { + if (document.appleState === "exact") return document.bytes; + const source = addAppleEntitlementToXML(document.source); + if (!source) return undefined; + const bytes = bytesWithOptionalBOM(source, document.bom); + const inspected = inspectEntitlementsBytes(root, document.absolutePath, bytes, document.mode); + return inspected.status === "safe" && inspected.document.appleState === "exact" + ? bytes + : undefined; +} + +/** + * Plans the exact native Sign in with Apple entitlement across every selected + * target entitlements variant. No Apple or Clerk credentials are retained. + */ +export async function planIOSAppleEntitlement( + options: IOSAppleEntitlementPlanOptions, +): Promise { + const normalized = { ...options, root: resolve(options.root) }; + const entitlementProbe = await planIOSAssociatedDomain({ + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + deferToPublishableKey: true, + allowMissingEntitlementsCreation: normalized.allowMissingEntitlementsCreation, + }); + if (entitlementProbe.status === "blocked") { + return blockedPlan( + normalized, + entitlementProbe.blockers.map((item) => blocker(item.code, item.message)), + entitlementProbe.targetName, + ); + } + + const files: IOSAppleEntitlementPlanFile[] = entitlementProbe.files.map((file) => ({ + path: file.path, + operation: file.operation, + ...(file.expectedHash ? { expectedHash: file.expectedHash } : {}), + })); + let allExact = files.length > 0 && files.every((file) => file.operation === "modify"); + for (const file of files) { + if (file.operation === "create") { + allExact = false; + continue; + } + const inspected = await inspectEntitlementsFile( + normalized.root, + resolve(normalized.root, file.path), + ); + if (inspected.status === "blocked") { + return blockedPlan(normalized, [inspected.blocker], entitlementProbe.targetName); + } + if (inspected.document.hash !== file.expectedHash) { + return blockedPlan( + normalized, + [blocker("stale-entitlements", `${file.path} changed while setup was inspected.`)], + entitlementProbe.targetName, + ); + } + if (inspected.document.appleState !== "exact") allExact = false; + } + + return { + ...planBase(normalized), + status: allExact ? "satisfied" : "ready", + ...(entitlementProbe.targetName ? { targetName: entitlementProbe.targetName } : {}), + files, + ...(entitlementProbe.missingEntitlementsSettings + ? { missingEntitlementsSettings: entitlementProbe.missingEntitlementsSettings } + : {}), + actions: allExact + ? [] + : [ + files.some((file) => file.operation === "create") + ? "Create and attach an iOS entitlements file with the Sign in with Apple entitlement set to Default." + : "Set the Sign in with Apple entitlement to Default in every selected-target iOS entitlements configuration.", + ], + blockers: [], + }; +} + +export async function prepareIOSAppleEntitlementMutation( + plan: IOSAppleEntitlementPlan, + options: IOSAppleEntitlementPrepareOptions = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-sign-in-with-apple-entitlement" || + resolve(plan.root) !== plan.root || + !plan.projectPath || + !plan.targetId || + plan.files.length === 0 + ) { + return blockPrepared( + plan, + "invalid-plan", + "The serialized Apple entitlement plan is incomplete.", + ); + } + + const baseByPath = new Map(); + for (const mutation of options.baseMutations ?? []) { + const path = resolve(mutation.path); + if ( + !isAbsolute(mutation.path) || + path !== mutation.path || + baseByPath.has(path) || + !(await pathIsSafelyWithinIOSRoot(plan.root, path)) || + !validBaseMutation(mutation) + ) { + return blockPrepared( + plan, + "invalid-plan", + "A caller-supplied base mutation is invalid, duplicated, or outside the invocation root.", + ); + } + baseByPath.set(path, mutation); + } + + // Compare the exact authorized bytes before reparsing. A concurrent edit + // that also makes the plist malformed is stale input, not a new structural + // blocker, and its newer bytes must remain untouched. + for (const file of plan.files) { + const absolutePath = resolve(plan.root, file.path); + if (!(await pathIsSafelyWithinIOSRoot(plan.root, absolutePath))) { + return blockPrepared( + plan, + "invalid-plan", + "A planned entitlements path no longer resolves safely inside the invocation root.", + ); + } + if (file.operation === "create") { + try { + await lstat(absolutePath); + return { status: "stale", plan }; + } catch (error) { + if (!isMissingFileError(error)) return { status: "stale", plan }; + } + continue; + } + if (!file.expectedHash) + return blockPrepared(plan, "invalid-plan", "A planned file hash is missing."); + const current = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (current.status !== "ok" || hashIOSFileBytes(current.bytes) !== file.expectedHash) { + return { status: "stale", plan }; + } + } + + const replanned = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if (!samePlanFiles(plan.files, replanned.files)) return { status: "stale", plan }; + if (plan.status === "satisfied") { + return replanned.status === "satisfied" + ? { status: "satisfied", plan: replanned } + : { status: "stale", plan }; + } + if (replanned.status !== "ready") return { status: "stale", plan }; + + const createFile = plan.files.find((file) => file.operation === "create"); + if (createFile) { + if ( + plan.files.length !== 1 || + !plan.missingEntitlementsSettings || + createFile.path !== plan.missingEntitlementsSettings.entitlementsPath + ) { + return blockPrepared( + plan, + "invalid-plan", + "The missing-entitlements Apple plan is internally inconsistent.", + ); + } + const entitlementsPath = resolve(plan.root, createFile.path); + const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const baseEntitlements = baseByPath.get(entitlementsPath); + const basePbx = baseByPath.get(pbxprojPath); + if (baseEntitlements && !isCreateMutation(baseEntitlements)) { + return { status: "stale", plan }; + } + if (basePbx && isCreateMutation(basePbx)) { + return blockPrepared( + plan, + "invalid-plan", + "The base Xcode mutation must replace an existing file.", + ); + } + const settings = await prepareIOSMissingEntitlementsSettingsMutation( + plan.missingEntitlementsSettings, + basePbx as IOSExistingFileMutation | undefined, + ); + if (settings.status === "stale") return { status: "stale", plan }; + if (settings.status !== "ready") { + return blockPrepared( + plan, + "invalid-plan", + "The iOS entitlements build settings could not be prepared safely.", + ); + } + + const expectedParentIdentity = + plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + const synchronizedRootPath = plan.missingEntitlementsSettings.synchronizedRootPath; + const boundary = await prepareIOSFileMutationBoundary(plan.root, entitlementsPath); + if ( + !expectedParentIdentity || + !synchronizedRootPath || + dirname(entitlementsPath) !== resolve(plan.root, synchronizedRootPath) + ) { + return blockPrepared( + plan, + "invalid-plan", + "The entitlements destination no longer matches its synchronized target root.", + ); + } + if ( + !boundary || + boundary.parentIdentity.device !== expectedParentIdentity.device || + boundary.parentIdentity.inode !== expectedParentIdentity.inode + ) { + return { status: "stale", plan }; + } + + let createMutation: IOSCreateFileMutation; + if (baseEntitlements) { + if (!isDeepStrictEqual(baseEntitlements.boundary, boundary)) { + return { status: "stale", plan }; + } + const inspected = inspectEntitlementsBytes( + plan.root, + entitlementsPath, + baseEntitlements.candidateBytes, + baseEntitlements.mode, + ); + if (inspected.status === "blocked") { + return blockPrepared(plan, inspected.blocker.code, inspected.blocker.message); + } + const candidateBytes = candidateWithApple(plan.root, inspected.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + "The composed entitlements candidate could not be updated safely.", + ); + } + createMutation = { + ...baseEntitlements, + boundary: baseEntitlements.boundary, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + }; + } else { + const candidateBytes = newEntitlementsBytes(appleEntitlementLines()); + createMutation = { + kind: "create", + path: entitlementsPath, + boundary, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: 0o644, + }; + } + return preparedWithHiddenMutations( + plan, + [createMutation, settings.mutation], + [...(baseEntitlements ? [entitlementsPath] : []), ...(basePbx ? [pbxprojPath] : [])], + ); + } + + const mutations: IOSExistingFileMutation[] = []; + const consumed: string[] = []; + for (const file of plan.files) { + if (file.operation !== "modify" || !file.expectedHash) { + return blockPrepared( + plan, + "invalid-plan", + "The Apple entitlement plan has an invalid file entry.", + ); + } + const absolutePath = resolve(plan.root, file.path); + const current = await inspectEntitlementsFile(plan.root, absolutePath); + if (current.status === "blocked" || current.document.hash !== file.expectedHash) { + return { status: "stale", plan }; + } + const base = baseByPath.get(absolutePath); + if (base && isCreateMutation(base)) return { status: "stale", plan }; + const boundary = await prepareIOSFileMutationBoundary(plan.root, absolutePath); + if (!boundary || (base && !isDeepStrictEqual(base.boundary, boundary))) { + return { status: "stale", plan }; + } + if ( + base && + (base.originalHash !== file.expectedHash || + base.mode !== current.document.mode || + hashIOSFileBytes(base.originalBytes) !== current.document.hash) + ) { + return { status: "stale", plan }; + } + const source = base + ? inspectEntitlementsBytes(plan.root, absolutePath, base.candidateBytes, base.mode) + : current; + if (source.status === "blocked") { + return blockPrepared(plan, source.blocker.code, source.blocker.message); + } + if (source.document.appleState === "exact") { + if (base && current.document.appleState !== "exact") { + mutations.push(base); + consumed.push(absolutePath); + } + continue; + } + const candidateBytes = candidateWithApple(plan.root, source.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + `${file.path} could not be updated without rewriting unrelated plist content.`, + ); + } + mutations.push({ + path: absolutePath, + boundary: base?.boundary ?? boundary, + originalBytes: base?.originalBytes ?? current.document.bytes, + originalHash: base?.originalHash ?? current.document.hash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: base?.mode ?? current.document.mode, + }); + if (base) consumed.push(absolutePath); + } + if (mutations.length === 0) return { status: "satisfied", plan }; + return preparedWithHiddenMutations(plan, mutations, consumed); +} + +export async function validatePreparedIOSAppleEntitlement( + prepared: Extract, +): Promise { + if ( + prepared.plan.missingEntitlementsSettings && + !(await validateIOSMissingEntitlementsSettingsPostcondition( + prepared.plan.missingEntitlementsSettings, + )) + ) { + return false; + } + const current = await planIOSAppleEntitlement({ + root: prepared.plan.root, + projectPath: prepared.plan.projectPath, + targetId: prepared.plan.targetId, + }); + const expectedPaths = prepared.plan.files.map((file) => file.path).sort(); + return ( + current.status === "satisfied" && + current.files + .map((file) => file.path) + .sort() + .every((path, index) => path === expectedPaths[index]) && + current.files.length === expectedPaths.length + ); +} + +export async function applyIOSAppleEntitlement( + plan: IOSAppleEntitlementPlan, +): Promise { + const prepared = await prepareIOSAppleEntitlementMutation(plan); + if (prepared.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if (prepared.status === "stale") return { status: "stale", plan: prepared.plan }; + if (prepared.status === "satisfied") return { status: "satisfied", plan: prepared.plan }; + const result = await applyIOSFileTransaction(prepared.mutations, [ + async () => validatePreparedIOSAppleEntitlement(prepared), + ]); + return { status: result.status, plan: prepared.plan }; +} diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts new file mode 100644 index 000000000..e07dce2c5 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -0,0 +1,1201 @@ +import { lstat, realpath } from "node:fs/promises"; +import { basename, dirname, relative, resolve, sep } from "node:path"; +import { dim, yellow } from "../../../lib/color.ts"; +import { + CliError, + ERROR_CODE, + type ErrorCode, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; +import { + prepareIOSSDKInstallMutation, + validateIOSSDKInstallPostcondition, + type IOSSDKInstallPlan, + type PreparedIOSSDKInstallMutation, +} from "./install-sdk.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { + prepareIOSDirectConfigMutation, + validatePreparedIOSDirectConfig, + type IOSDirectConfigPlan, + type IOSDirectConfigPreparedMutation, +} from "./direct-config.ts"; +import { + applyIOSFileTransaction, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, + type IOSAssociatedDomainPlan, + type PreparedIOSAssociatedDomainMutation, +} from "./associated-domain.ts"; +import { + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, + type IOSAppleEntitlementPlan, + type PreparedIOSAppleEntitlementMutation, +} from "./apple-entitlement.ts"; +import { + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, + validatePreparedIOSPrebuiltAuth, + type IOSPrebuiltAuthPlan, + type PreparedIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; +import { + buildIOSLocalSetupProposal, + createIOSLocalSetupContext, + planIOSPrebuiltAuthRuntimeBlockers, + type IOSLocalSetupProposal, +} from "./local-plan.ts"; + +function iosSetupError(message: string, code: ErrorCode = ERROR_CODE.IOS_SETUP_BLOCKED): CliError { + return new CliError(message, { code }); +} + +export interface ApplyIOSLocalSetupOptions { + root: string; + target?: string; + yes: boolean; + agent: boolean; + allowDirty: boolean; + /** Explicit native Apple opt-in. Undefined allows a human prompt. */ + signInWithApple?: boolean; + /** Explicit prebuilt AuthView opt-in. Undefined allows a default-off human prompt. */ + prebuiltAuthUI?: boolean; +} + +export type IOSLocalSetupResult = Pick< + IOSLocalSetupProposal, + | "setupPlan" + | "nativeReadiness" + | "unverifiedAppIdPrefixSuggestion" + | "sdkInstallPlan" + | "directConfigPlan" + | "associatedDomainPlan" + | "appleEntitlementPlan" + | "prebuiltAuthPlan" + | "prebuiltAuthAppleEntitlementPlan" + | "prebuiltAuthRequested" + | "prebuiltAuthActive" + | "nativeAppleRequested" +> & { + targetName: string; + /** Authentication must return an exact app ID and development key before commit. */ + requiresLinkedApp: boolean; + /** The approved local transaction consumes the linked development publishable key. */ + requiresDevelopmentKey: boolean; + /** A preserved runtime configuration requires the developer to choose its Clerk application. */ + requiresExplicitApplication: boolean; +}; + +/** @internal Test-only hook used to prove aggregate post-write rollback. */ +export interface ApplyIOSPlannedLocalSetupOptions { + beforePostWriteValidation?: () => void | Promise; +} + +type GitPathState = "clean" | "dirty" | "not-repository" | "unknown"; +const GIT_PATH_STATE_TIMEOUT_MS = 5_000; + +async function hasGitMarkerInAncestors(start: string): Promise { + let directory = resolve(start); + while (true) { + try { + await lstat(resolve(directory, ".git")); + return true; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(directory); + if (parent === directory) return false; + directory = parent; + } +} + +async function gitPathState(absolutePath: string): Promise { + const projectDirectory = dirname(absolutePath); + try { + const repository = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + cwd: projectDirectory, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + const repositoryRoot = (await new Response(repository.stdout).text()).trim(); + const repositoryExitCode = await repository.exited; + if (repository.signalCode != null) return "unknown"; + if (repositoryExitCode !== 0) { + return (await hasGitMarkerInAncestors(projectDirectory)) ? "unknown" : "not-repository"; + } + if (repositoryRoot === "") return "unknown"; + + const canonicalRepositoryRoot = await realpath(repositoryRoot); + let canonicalAbsolutePath: string; + try { + canonicalAbsolutePath = await realpath(absolutePath); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + return "unknown"; + } + canonicalAbsolutePath = resolve( + await realpath(dirname(absolutePath)), + basename(absolutePath), + ); + } + const path = relative(canonicalRepositoryRoot, canonicalAbsolutePath); + if (path === "" || path === ".." || path.startsWith(`..${sep}`)) return "unknown"; + + const status = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", path], + { + cwd: canonicalRepositoryRoot, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }, + ); + const output = await new Response(status.stdout).text(); + const statusExitCode = await status.exited; + if (status.signalCode != null || statusExitCode !== 0) return "unknown"; + return output.trim() === "" ? "clean" : "dirty"; + } catch { + return "unknown"; + } +} + +function formatProducts(products: string[]): string { + if (products.length === 1) return products[0]!; + return `${products.slice(0, -1).join(", ")} and ${products.at(-1)}`; +} + +function directConfigNeedsWrite(plan: IOSDirectConfigPlan | undefined): boolean { + const changes = plan?.changes; + return ( + plan?.status === "ready" && + changes != null && + (changes.clerkKitImport === "insert" || + changes.configuration !== "verify-existing" || + changes.environment === "insert") + ); +} + +function associatedDomainNeedsWrite( + plan: IOSAssociatedDomainPlan | undefined, +): plan is IOSAssociatedDomainPlan { + return plan?.status === "ready"; +} + +function blockerList(blockers: Array<{ message: string }>): string { + return blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); +} + +async function validatePrebuiltAuthRuntimePostcondition( + setup: IOSLocalSetupResult, +): Promise { + if (!setup.prebuiltAuthActive) return true; + if (setup.nativeReadiness.target.status !== "selected") return false; + const target = setup.nativeReadiness.target; + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: target.targetId, + exhaustiveContainerDiscovery: true, + }); + if ( + hasIncompleteIOSContainerDiscovery(inspection) || + inspection.selection.state !== "selected" || + inspection.selection.targetId !== target.targetId || + inspection.selection.projectPath !== target.projectPath + ) { + return false; + } + const setupPlan = buildIOSSetupPlan(inspection); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + return configureStep?.status === "satisfied" && environmentStep?.status === "satisfied"; +} + +/** + * Inspects, previews, and authorizes the local iOS setup without writing it. + * The returned redacted plans are prepared again and committed only after an + * exact Clerk application and development publishable key have been resolved. + */ +export async function applyIOSLocalSetup( + options: ApplyIOSLocalSetupOptions, +): Promise { + const inspection = await withSpinner("Inspecting Xcode project...", async () => + inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + }), + ); + const context = createIOSLocalSetupContext(inspection); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + throw iosSetupError( + "Xcode project discovery was incomplete, so Clerk cannot safely select an iOS application target. Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const selection = inspection.selection; + if (selection.state !== "selected") { + if (selection.state === "ambiguous") { + const candidates = selection.candidates + .map( + (candidate) => + `${candidate.targetName} (${candidate.targetId}, ${candidate.projectPath})`, + ) + .join(", "); + throwUsageError( + `More than one iOS application target is eligible: ${candidates}. Rerun with --target ; if IDs collide across copied projects, run the command from the intended project's directory.`, + ); + } + if (selection.state === "not-found") { + throwUsageError( + `The iOS target "${selection.requested}" was not found. Available targets: ${ + selection.candidates.join(", ") || "none" + }.`, + ); + } + throw iosSetupError( + "No usable iOS application target was found.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + + const selectedTarget = context.selectedTarget; + if (!selectedTarget) { + throw iosSetupError( + "The selected iOS target could not be resolved safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const productDecision = context.productDecision; + if (!productDecision) { + throw iosSetupError( + "The selected iOS target could not be planned safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + if (productDecision === "unknown") { + throw iosSetupError( + "The selected target's Swift source membership could not be inspected completely, so Clerk cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the Xcode source-membership diagnostics, then rerun clerk init.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + + const proposal = await buildIOSLocalSetupProposal(context, { + root: options.root, + allowDirty: options.allowDirty, + prebuiltAuthUI: options.prebuiltAuthUI, + signInWithApple: options.signInWithApple, + ...(!options.agent && !options.yes + ? { + resolvePrebuiltAuthRequest: async ({ targetName }: { targetName: string }) => + confirm({ + message: `Add ClerkKitUI's prebuilt authentication UI to ${targetName}?`, + default: false, + }), + resolveNativeAppleRequest: async ({ bundleIdentifier }: { bundleIdentifier: string }) => + confirm({ + message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + default: false, + }), + } + : {}), + }); + const { + inspectedPrebuiltAuthPlan, + prebuiltAuthPlan, + prebuiltAuthRequested, + prebuiltAuthActive, + installPlan, + reviewOnlyUnattributedInstall, + directConfigPlan, + plannedAssociatedDomain, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthAppleEntitlementPlan, + nativeAppleRequested, + nativeReadiness, + hasCustomConfigure, + hasSupportedCustomConfigure, + prebuiltRuntimeBlockers, + } = proposal; + if (!installPlan || !plannedAssociatedDomain || !inspectedPrebuiltAuthPlan) { + throw iosSetupError( + "The selected iOS target did not produce one complete local setup proposal.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") { + throw iosSetupError( + `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList( + inspectedPrebuiltAuthPlan.blockers, + )}`, + ); + } + if ( + directConfigNeedsWrite(directConfigPlan) && + prebuiltAuthPlan?.status === "ready" && + directConfigPlan?.sourcePath === prebuiltAuthPlan.sourcePath + ) { + throw iosSetupError( + "The approved iOS setup resolved the Clerk initializer and prebuilt AuthView scaffold to the same Swift source unexpectedly. No local files were changed; review the app root and rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if ( + nativeReadiness.target.status !== "selected" || + nativeReadiness.target.bundleIdentifier.status !== "resolved" + ) { + throw iosSetupError( + "The selected iOS target does not have one proven Bundle ID across all build configurations. No local files were changed; resolve PRODUCT_BUNDLE_IDENTIFIER, then rerun clerk init.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + if (appleEntitlementPlan?.status === "blocked") { + throw iosSetupError( + `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList( + appleEntitlementPlan.blockers, + )}`, + ); + } + if (directConfigPlan?.status === "blocked") { + throw iosSetupError( + `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList( + directConfigPlan.blockers, + )}`, + ); + } + if ( + (productDecision === "prebuilt" || prebuiltAuthActive) && + selectedTarget.swift.configureCalls.length === 0 && + !directConfigPlan + ) { + throw iosSetupError( + "The fresh SwiftUI target was not edited because the selected runtime configuration could not be proven. Configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.", + ); + } + if (hasCustomConfigure && !hasSupportedCustomConfigure) { + throw iosSetupError( + "A custom Clerk.configure(...) source was found, but it is not one unambiguous call in the selected app's startup initializer. clerk init preserved it and made no local or remote changes. Confirm the shipping configuration manually, then rerun the command.", + ); + } + if (prebuiltAuthActive) { + if (prebuiltRuntimeBlockers.length > 0) { + throw iosSetupError( + `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${prebuiltRuntimeBlockers + .map((message) => ` • ${message}`) + .join("\n")}`, + ); + } + } + + if (installPlan.status === "satisfied") { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${ + selection.targetName + }.`, + ), + ); + } + if (reviewOnlyUnattributedInstall) { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${ + selection.targetName + }, but package attribution is not represented in this project graph. The existing Xcode package graph will be left unchanged.`, + ), + ); + } else if (installPlan.status === "blocked") { + throw iosSetupError( + `The Clerk iOS SDK could not be installed automatically:\n${blockerList( + installPlan.blockers, + )}`, + ); + } + const plannedPaths: Array<{ absolutePath: string; displayPath: string }> = []; + if (installPlan.status === "ready") { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + if (directConfigNeedsWrite(directConfigPlan) && directConfigPlan?.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, directConfigPlan.sourcePath), + displayPath: directConfigPlan.sourcePath, + }); + } + if (prebuiltAuthPlan?.status === "ready" && prebuiltAuthPlan.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, prebuiltAuthPlan.sourcePath), + displayPath: prebuiltAuthPlan.sourcePath, + }); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of associatedDomainPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (appleEntitlementPlan?.status === "ready") { + if (appleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of appleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (!options.allowDirty) { + const uniquePaths = [ + ...new Map(plannedPaths.map((path) => [path.absolutePath, path])).values(), + ]; + for (const path of uniquePaths) { + const state = await gitPathState(path.absolutePath); + if (state === "dirty") { + throw iosSetupError( + `${path.displayPath} already has local changes. Commit or stash them, or rerun with --allow-dirty to preserve and build on those exact bytes.`, + ERROR_CODE.IOS_WORKTREE_UNSAFE, + ); + } + if (state === "unknown") { + throw iosSetupError( + `Git could not verify whether ${path.displayPath} has local changes. Resolve the Git error, or rerun with --allow-dirty to build on the current exact bytes.`, + ERROR_CODE.IOS_WORKTREE_UNSAFE, + ); + } + } + } + + const hasLocalWrites = + installPlan.status === "ready" || + directConfigNeedsWrite(directConfigPlan) || + prebuiltAuthPlan?.status === "ready" || + associatedDomainNeedsWrite(associatedDomainPlan) || + appleEntitlementPlan?.status === "ready" || + prebuiltAuthAppleEntitlementPlan?.status === "ready"; + if (hasLocalWrites) { + log.info("\nclerk init will make the following local iOS changes:\n"); + } else if (directConfigPlan || appleEntitlementPlan || prebuiltAuthPlan) { + log.info("\nclerk init will perform the following read-only iOS verification:\n"); + } + if (installPlan.status === "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + for (const action of installPlan.actions) log.info(` ${action}`); + } + if (directConfigPlan) { + const operation = directConfigNeedsWrite(directConfigPlan) ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${directConfigPlan.sourcePath}`); + for (const action of directConfigPlan.actions) log.info(` ${action}`); + log.info( + dim( + " The linked development publishable key will remain in memory and is redacted from the preview and command output.", + ), + ); + } + if (hasSupportedCustomConfigure) { + log.info( + dim( + " PRESERVE Custom Clerk.configure(...) publishable-key source. Its value will not be inspected; the developer must select the existing Clerk application it belongs to.", + ), + ); + } + if (prebuiltAuthPlan) { + const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${prebuiltAuthPlan.sourcePath}`); + for (const action of prebuiltAuthPlan.actions) log.info(` ${action}`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings && installPlan.status !== "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + for (const action of associatedDomainPlan.actions) log.info(` ${action}`); + if (associatedDomainPlan.requiresPublishableKey) { + log.info( + dim( + " The exact linked development host will be resolved after authentication and is redacted from this preview.", + ), + ); + } + } + if (appleEntitlementPlan?.status === "ready") { + const alreadyPreviewedEntitlements = new Set( + associatedDomainNeedsWrite(associatedDomainPlan) + ? associatedDomainPlan.files.map((file) => file.path) + : [], + ); + if ( + appleEntitlementPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of appleEntitlementPlan.files) { + if (!alreadyPreviewedEntitlements.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of appleEntitlementPlan.actions) log.info(` ${action}`); + } else if (appleEntitlementPlan?.status === "satisfied") { + log.info(dim("\n The selected target already has the native Sign in with Apple entitlement.")); + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + log.info( + dim( + "\n Conditional AuthView capability change (only if Apple is enabled for the linked instance):", + ), + ); + const alreadyPreviewedPaths = new Set(); + if (installPlan.status === "ready") { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); + } + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + const projectFile = `${selection.projectPath}/project.pbxproj`; + if (!alreadyPreviewedPaths.has(projectFile)) { + log.info(` ${yellow("MODIFY")} ${projectFile}`); + } + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + if (!alreadyPreviewedPaths.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of prebuiltAuthAppleEntitlementPlan.actions) { + log.info(` If Apple is enabled: ${action}`); + } + } + if (prebuiltAuthActive) { + log.info( + dim( + "\n After authentication, clerk init will inspect the methods available to AuthView. If Apple is enabled for this instance, it will add or verify the required local Sign in with Apple entitlement without enabling or changing the Clerk Apple connection.", + ), + ); + } + if (installPlan.status === "ready") { + log.info(dim("\n Package resolution and xcodebuild will not run.")); + } + log.info( + dim( + nativeAppleRequested + ? "\n After authentication, clerk init will inspect Native API, iOS registration, and the native Apple connection before separately previewing additive remote changes." + : "\n After authentication, clerk init will inspect Native API and iOS registration state and separately preview any additive remote changes.", + ), + ); + log.blank(); + + if (hasLocalWrites && options.agent && !options.yes) { + throwUsageError( + "Changing an Xcode project in agent mode requires explicit consent. Review `clerk init --dry-run`, then rerun `clerk init --yes`.", + ); + } + if (hasLocalWrites && !options.yes) { + const proceed = await confirm({ + message: "Apply these local iOS changes?", + default: false, + }); + if (!proceed) throwUserAbort(); + } + + return { + ...proposal, + targetName: selection.targetName, + requiresLinkedApp: true, + requiresDevelopmentKey: + directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, + requiresExplicitApplication: + hasSupportedCustomConfigure || directConfigPlan?.changes?.configuration === "verify-existing", + }; +} + +function directFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function prebuiltAuthFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function preparedSDKBlockers(prepared: PreparedIOSSDKInstallMutation): string { + return prepared.status === "blocked" ? blockerList(prepared.plan.blockers) : ""; +} + +async function prepareSDKForCommit( + plan: IOSSDKInstallPlan | undefined, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSSDKInstallMutation(plan); + if (prepared.status === "stale") { + throw iosSetupError( + "The Xcode project changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The Clerk iOS SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers( + prepared, + )}`, + ); + } + return prepared; +} + +async function preparePrebuiltAuthForCommit( + plan: IOSPrebuiltAuthPlan | undefined, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + if (prepared.status === "stale") { + throw iosSetupError( + "The Swift authentication view changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The prebuilt AuthView flow could no longer be prepared safely. No local setup changes were written:\n${blockerList( + prepared.plan.blockers, + )}`, + ); + } + return prepared; +} + +async function prepareAssociatedDomainForCommit( + plan: IOSAssociatedDomainPlan | undefined, + publishableKey: string | undefined, + basePbxMutation?: IOSExistingFileMutation, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSAssociatedDomainMutation(plan, publishableKey, { + basePbxMutation, + }); + if (prepared.status === "stale") { + throw iosSetupError( + "An entitlements file changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + const reasons = blockerList(prepared.plan.blockers); + throw iosSetupError( + `The Clerk Associated Domain could no longer be prepared safely. No local setup changes were written${ + reasons ? `:\n${reasons}` : "." + }`, + ); + } + return prepared; +} + +async function prepareAppleEntitlementForCommit( + plan: IOSAppleEntitlementPlan | undefined, + baseMutations: readonly IOSFileMutation[], +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSAppleEntitlementMutation(plan, { + baseMutations, + }); + if (prepared.status === "stale") { + throw iosSetupError( + "An iOS entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The Sign in with Apple entitlement could no longer be prepared safely. No local setup changes were written:\n${blockerList( + prepared.plan.blockers, + )}`, + ); + } + return prepared; +} + +function composeAppleMutations( + baseMutations: readonly IOSFileMutation[], + prepared: PreparedIOSAppleEntitlementMutation | undefined, +): IOSFileMutation[] { + if (prepared?.status !== "ready") return [...baseMutations]; + const consumed = new Set(prepared.consumedBaseMutationPaths); + return [ + ...baseMutations.filter((mutation) => !consumed.has(resolve(mutation.path))), + ...prepared.mutations, + ]; +} + +function assertUniqueMutationPaths(mutations: readonly IOSFileMutation[]): void { + const paths = mutations.map((mutation) => resolve(mutation.path)); + if (new Set(paths).size !== paths.length) { + throw iosSetupError( + "The approved iOS setup produced overlapping file mutations. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } +} + +async function validateSatisfiedAssociatedDomain(plan: IOSAssociatedDomainPlan): Promise { + const current = await planIOSAssociatedDomain({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return ( + current.status === "satisfied" && + (plan.expectedDomain == null || current.expectedDomain === plan.expectedDomain) + ); +} + +async function validateSatisfiedAppleEntitlement(plan: IOSAppleEntitlementPlan): Promise { + const current = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return current.status === "satisfied"; +} + +async function validateSatisfiedPrebuiltAuth(plan: IOSPrebuiltAuthPlan): Promise { + const current = await planIOSPrebuiltAuth({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return current.status === "satisfied" && current.sourcePath === plan.sourcePath; +} + +function requireDevelopmentKey( + setup: IOSLocalSetupResult, + publishableKey: string | undefined, +): string { + const planNeedsKey = Boolean( + setup.directConfigPlan || setup.associatedDomainPlan?.requiresPublishableKey, + ); + if (planNeedsKey !== setup.requiresDevelopmentKey) { + throw iosSetupError( + "The approved iOS setup plan is internally inconsistent. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (!planNeedsKey) return ""; + if (!publishableKey) { + throw iosSetupError( + "The linked Clerk application's development publishable key was not available. No local setup changes were written.", + ERROR_CODE.IOS_PUBLISHABLE_KEY_UNAVAILABLE, + ); + } + return publishableKey; +} + +function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { + if (setup.prebuiltAuthRequested && !setup.prebuiltAuthPlan) { + throw iosSetupError( + "The approved iOS setup selected prebuilt authentication without a validated source plan. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const expectedPrebuiltAuthActive = + setup.prebuiltAuthRequested || setup.prebuiltAuthPlan?.status === "satisfied"; + if (setup.prebuiltAuthActive !== expectedPrebuiltAuthActive) { + throw iosSetupError( + "The approved iOS setup contains inconsistent prebuilt authentication state. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (setup.prebuiltAuthAppleEntitlementPlan && !setup.prebuiltAuthActive) { + throw iosSetupError( + "The approved iOS setup contains an unselected AuthView capability plan. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if ( + setup.directConfigPlan?.sourcePath && + setup.prebuiltAuthPlan?.status === "ready" && + setup.directConfigPlan.sourcePath === setup.prebuiltAuthPlan.sourcePath + ) { + throw iosSetupError( + "The approved iOS setup contains overlapping Swift source mutations. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const runtimePlans = [setup.directConfigPlan].filter((plan) => plan != null); + const plans: Array<{ root: string; projectPath: string; targetId: string }> = [ + setup.sdkInstallPlan, + ...runtimePlans, + ].filter((plan) => plan != null); + if (setup.prebuiltAuthPlan) plans.push(setup.prebuiltAuthPlan); + if (setup.associatedDomainPlan) plans.push(setup.associatedDomainPlan); + if (setup.appleEntitlementPlan) plans.push(setup.appleEntitlementPlan); + if (setup.prebuiltAuthAppleEntitlementPlan) { + plans.push(setup.prebuiltAuthAppleEntitlementPlan); + } + if (setup.nativeReadiness.target.status !== "selected") { + throw iosSetupError( + "The approved iOS setup no longer identifies one selected native target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + plans.push({ + root: setup.nativeReadiness.root, + projectPath: setup.nativeReadiness.target.projectPath, + targetId: setup.nativeReadiness.target.targetId, + }); + const selection = plans[0]; + if ( + selection && + plans.some( + (plan) => + plan.root !== selection.root || + plan.projectPath !== selection.projectPath || + plan.targetId !== selection.targetId, + ) + ) { + throw iosSetupError( + "The approved iOS setup no longer identifies one consistent Xcode target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } +} + +/** + * Commits a previously previewed iOS setup after authentication. Fresh direct + * configuration combines project.pbxproj and the Swift entry source in one + * guarded local transaction. Existing custom key sources are preserved and + * are never rewritten or interpreted. + */ +export async function applyIOSPlannedLocalSetup( + setup: IOSLocalSetupResult, + publishableKey?: string, + options: ApplyIOSPlannedLocalSetupOptions = {}, +): Promise { + assertCoherentLocalSetup(setup); + if (setup.prebuiltAuthActive) { + if (setup.nativeReadiness.target.status !== "selected") { + throw iosSetupError( + "The approved prebuilt AuthView setup no longer identifies one selected iOS target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: setup.nativeReadiness.target.targetId, + exhaustiveContainerDiscovery: true, + }); + if ( + hasIncompleteIOSContainerDiscovery(inspection) || + inspection.selection.state !== "selected" || + inspection.selection.targetId !== setup.nativeReadiness.target.targetId || + inspection.selection.projectPath !== setup.nativeReadiness.target.projectPath + ) { + throw iosSetupError( + "The approved prebuilt AuthView setup no longer identifies the same exhaustively discovered Xcode target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers(inspection, setup.directConfigPlan); + if (runtimeBlockers.length > 0) { + throw iosSetupError( + `The approved prebuilt AuthView setup no longer proves its Clerk runtime prerequisites. No local setup changes were written:\n${runtimeBlockers + .map((message) => ` • ${message}`) + .join("\n")}`, + ERROR_CODE.IOS_SETUP_STALE, + ); + } + } + const key = requireDevelopmentKey(setup, publishableKey); + + const preparedSDK = await prepareSDKForCommit(setup.sdkInstallPlan); + const preparedPrebuiltAuth = await preparePrebuiltAuthForCommit(setup.prebuiltAuthPlan); + + if (setup.directConfigPlan) { + const preparedDirect = await prepareIOSDirectConfigMutation(setup.directConfigPlan, key); + if (preparedDirect.status === "stale") { + throw iosSetupError( + "The Swift app entry source changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (preparedDirect.status === "blocked") { + throw iosSetupError( + `The Swift app entry source could no longer be configured safely. No local setup changes were written:\n${blockerList( + preparedDirect.plan.blockers, + )}`, + ); + } + // Verify an existing inline key before using the supplied key to derive + // its entitlements candidate. A mismatch must retain the dedicated + // wrong-application error and leave every file untouched. + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + + const baseMutations: IOSFileMutation[] = []; + const postconditions: Array<() => boolean | Promise> = []; + if (options.beforePostWriteValidation) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return true; + }); + } + if ( + preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ) { + baseMutations.push(preparedSDK.mutation); + } + if (preparedSDK) { + postconditions.push(async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)); + } + if (preparedAssociatedDomain?.status === "ready") { + baseMutations.push(...preparedAssociatedDomain.mutations); + postconditions.push(async () => + validatePreparedIOSAssociatedDomain(preparedAssociatedDomain), + ); + } else if (preparedAssociatedDomain?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan), + ); + } + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const mutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + if (preparedAppleEntitlement?.status === "ready") { + postconditions.push(async () => + validatePreparedIOSAppleEntitlement(preparedAppleEntitlement), + ); + } else if (preparedAppleEntitlement?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan), + ); + } + // Commit the entitlements file and its Xcode settings before Swift starts + // depending on the configured SDK. A process interruption can then leave + // only harmless project prerequisites, never source that imports an + // unlinked package. + if (preparedDirect.status === "ready") { + mutations.push(directFileMutation(preparedDirect)); + postconditions.push(async () => validatePreparedIOSDirectConfig(preparedDirect)); + } else { + postconditions.push(async () => { + const verified = await prepareIOSDirectConfigMutation(setup.directConfigPlan!, key); + return verified.status === "satisfied"; + }); + } + if (preparedPrebuiltAuth?.status === "ready") { + mutations.push(prebuiltAuthFileMutation(preparedPrebuiltAuth)); + postconditions.push(async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)); + } else if (preparedPrebuiltAuth?.status === "satisfied") { + postconditions.push(async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)); + } + if (setup.prebuiltAuthActive) { + postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup)); + } + assertUniqueMutationPaths(mutations); + + if (mutations.length > 0) { + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(mutations, postconditions), + ); + if (result.status === "stale") { + throw iosSetupError( + "An iOS setup file changed while the approved changes were being committed. Any partial write was restored; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (result.status === "rolled-back") { + throw iosSetupError( + "The local iOS setup failed post-write validation and was restored byte-for-byte.", + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); + } + } + + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedDirect.status === "ready") { + log.success(`Clerk configured in ${preparedDirect.plan.sourcePath}`); + } else { + log.info(dim("The existing inline publishable key matches the linked Clerk application.")); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + if (preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + return; + } + + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + const baseMutations: IOSFileMutation[] = [ + ...(preparedAssociatedDomain?.status === "ready" ? preparedAssociatedDomain.mutations : []), + ...(preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ? [preparedSDK.mutation] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [prebuiltAuthFileMutation(preparedPrebuiltAuth)] + : []), + ]; + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + assertUniqueMutationPaths(localMutations); + + // SDK-only and custom-runtime routes apply their local candidates together + // after the developer has selected the intended Clerk application. + if (localMutations.length > 0) { + const postconditions: Array<() => boolean | Promise> = [ + ...(preparedSDK ? [async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)] : []), + ...(preparedAssociatedDomain?.status === "ready" + ? [async () => validatePreparedIOSAssociatedDomain(preparedAssociatedDomain)] + : preparedAssociatedDomain?.status === "satisfied" + ? [async () => validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan)] + : []), + ...(preparedAppleEntitlement?.status === "ready" + ? [async () => validatePreparedIOSAppleEntitlement(preparedAppleEntitlement)] + : preparedAppleEntitlement?.status === "satisfied" + ? [async () => validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan)] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)] + : preparedPrebuiltAuth?.status === "satisfied" + ? [async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)] + : []), + ...(setup.prebuiltAuthActive + ? [async () => validatePrebuiltAuthRuntimePostcondition(setup)] + : []), + ]; + if (options.beforePostWriteValidation) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return true; + }); + } + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(localMutations, postconditions), + ); + if (result.status === "stale") { + throw iosSetupError( + "The Xcode project changed after the preview. No SDK change was written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (result.status === "rolled-back") { + throw iosSetupError( + "The local iOS setup changed during post-write validation. The Clerk iOS SDK change was restored byte-for-byte; rerun clerk init.", + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); + } + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + } + + if (localMutations.length === 0) await options.beforePostWriteValidation?.(); +} diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index f9f04eb74..d7df893aa 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -304,6 +304,24 @@ struct MyApp: App { expect(updated).toContain(`webcredentials:${HOST}`); }); + test("matches only the associated-domain hostname case-insensitively", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", `webcredentials:${HOST.toUpperCase()}`), + ); + + expect((await planIOSAssociatedDomain(planOptions(root))).status).toBe("satisfied"); + + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", `WEBCREDENTIALS:${HOST}`), + ); + expect((await planIOSAssociatedDomain(planOptions(root))).status).toBe("ready"); + }); + test("preserves a comment immediately before a self-closing Associated Domains array", async () => { const root = await directFixture(); const path = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index 3c884baa1..6edb15102 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -35,7 +35,7 @@ import { validateIOSMissingEntitlementsSettingsPostcondition, type IOSMissingEntitlementsSettingsPlan, } from "./entitlements-settings.ts"; -import { inspectIOSProject } from "./inspect.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; import type { IOSAppTarget, IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; @@ -418,8 +418,26 @@ async function ownershipIsExclusive( } } +export function associatedDomainMatches(actual: string, expected: string): boolean { + const actualSeparator = actual.indexOf(":"); + const expectedSeparator = expected.indexOf(":"); + if (actualSeparator < 0 || expectedSeparator < 0) return false; + + const actualService = actual.slice(0, actualSeparator); + const expectedService = expected.slice(0, expectedSeparator); + if (actualService !== expectedService) return false; + + const splitHost = (value: string): [host: string, suffix: string] => { + const suffixStart = value.search(/[/?#]/); + return suffixStart < 0 ? [value, ""] : [value.slice(0, suffixStart), value.slice(suffixStart)]; + }; + const [actualHost, actualSuffix] = splitHost(actual.slice(actualSeparator + 1)); + const [expectedHost, expectedSuffix] = splitHost(expected.slice(expectedSeparator + 1)); + return actualHost.toLowerCase() === expectedHost.toLowerCase() && actualSuffix === expectedSuffix; +} + function exactDomainPresent(domains: readonly string[], expectedDomain: string): boolean { - return domains.includes(expectedDomain); + return domains.some((domain) => associatedDomainMatches(domain, expectedDomain)); } /** @@ -906,7 +924,9 @@ export async function validatePreparedIOSAssociatedDomain( } const inspection = await inspectIOSProject(prepared.plan.root, { target: prepared.plan.targetId, + exhaustiveContainerDiscovery: true, }); + if (hasIncompleteIOSContainerDiscovery(inspection)) return false; const target = selectedTarget(inspection, prepared.plan.projectPath, prepared.plan.targetId); if (!target) return false; if ( diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 0609f0864..0de416451 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -4,7 +4,8 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; -import type { IOSDiagnostic } from "./types.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import type { IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; const temporaryDirectories: string[] = []; @@ -695,7 +696,7 @@ describe("inspectTargetBuildConfigurations", () => { }); test("preserves dangling target configurations as blocking placeholders", async () => { - const { configurations, diagnostics } = await inspectFixture({ + const { configurations, diagnostics, root } = await inspectFixture({ targetConfigurationIds: ["target-debug", "missing-target-release"], }); @@ -711,6 +712,50 @@ describe("inspectTargetBuildConfigurations", () => { message: expect.stringContaining("missing-target-release"), }), ); + + const inspection: IOSProjectInspectionResult = { + schemaVersion: 1, + platform: "ios", + root, + workspaces: [], + projects: [], + appTargets: [ + { + id: "target", + name: "Example", + projectPath: "Example.xcodeproj", + configurations: configurations.map(({ model }) => model), + packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, + swift: { + sourceFilesScanned: 0, + evidenceComplete: true, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + appRootEvidence: [], + environmentInjections: [], + rootEnvironmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent", + }, + }, + ], + selection: { + state: "selected", + targetId: "target", + targetName: "Example", + projectPath: "Example.xcodeproj", + }, + localPublishableKey: { state: "missing" }, + generatedProject: null, + diagnostics, + }; + expect( + buildIOSSetupPlan(inspection).steps.find(({ id }) => id === "register-native-application"), + ).toMatchObject({ status: "blocked" }); }); test("taints target settings when the project configuration list is incomplete", async () => { diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 9f3d5c66e..654d56f56 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -273,6 +273,38 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(beforeSecondApply); }); + test("configures a SwiftData app with a WindowGroup scene modifier", async () => { + const root = await fixture(); + await replaceSource( + root, + `import SwiftData +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: Item.self) + } +} +`, + ); + + expect(hasExactIOSSwiftUIAppContentRoot(await source(root))).toBe(true); + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("ready"); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("applied"); + const configured = await source(root); + expect(configured).toContain(".environment(Clerk.shared)"); + expect(configured).toContain(".modelContainer(for: Item.self)"); + expect(configured.indexOf(".environment(Clerk.shared)")).toBeLessThan( + configured.indexOf(".modelContainer(for: Item.self)"), + ); + }); + test("inserts configuration first in one existing initializer", async () => { const root = await fixture(); await replaceSource( @@ -340,6 +372,34 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("blocks invalid EnvironmentValues overloads at the WindowGroup root", async () => { + for (const keyPath of ["\\.self", ".self"]) { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView().environment(${keyPath}, Clerk.shared) + } + } +} +`, + ); + + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("conflicting-environment"); + expect(await readFile(appSourcePath(root))).toEqual(before); + } + }); + test("refuses indirect Clerk access before an existing inline configuration", async () => { const root = await fixture(); await replaceSource( @@ -754,6 +814,14 @@ struct MyApp: App { const root = await fixture(); const deepRoot = join(root, "a", "b", "c", "d"); await createIOSFixture(deepRoot, { clerkSDK: false, includeKey: false }); + const deepProjectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); + await writeFile( + deepProjectPath, + (await readFile(deepProjectPath, "utf8")).replaceAll( + IOS_FIXTURE_IDS.appTarget, + IOS_FIXTURE_IDS.secondTarget, + ), + ); await updateProject(deepRoot, (objects) => { objects[IOS_FIXTURE_IDS.appFile]!.path = "../../../../../MyApp/MyAppApp.swift"; }); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index b181b049d..72750f527 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -15,7 +15,17 @@ import { type IOSExistingFileMutation, type IOSFileMutationBoundary, } from "./file-transaction.ts"; -import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import { + hasIncompleteIOSContainerDiscovery, + inspectIOSProject, + inspectIOSSourceMembership, +} from "./inspect.ts"; +import { + inspectSwiftUIAppRoot, + inspectSwiftUIAppRootWithStatus, + type SwiftUIAppRootStructure, + type SwiftUIRootExpression, +} from "./swift-app-root.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; export interface IOSDirectConfigPlanOptions { @@ -138,10 +148,10 @@ interface AppStructure { source: string; sanitized: string; newline: "\n" | "\r\n"; - appType: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; + appType: SwiftUIAppRootStructure["appType"]; initializer?: Range & { openingBrace: number; closingBrace: number }; - body: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; - root: Range & { modifierStarts: number[] }; + body: SwiftUIAppRootStructure["body"]; + root: SwiftUIAppRootStructure["root"]; hasClerkKitImport: boolean; importInsertion: number; existingPublishableKey?: string; @@ -222,12 +232,6 @@ function skipWhitespace(source: string, start: number, end = source.length): num return cursor; } -function trimWhitespaceEnd(source: string, start: number, end: number): number { - let cursor = end; - while (cursor > start && /\s/.test(source[cursor - 1] ?? "")) cursor -= 1; - return cursor; -} - function matchingDelimiter( source: string, opening: number, @@ -503,64 +507,6 @@ function importInsertionPosition( return last?.end; } -function appTypeRange( - sanitized: string, - index: SwiftStructuralIndex, -): AppStructure["appType"] | undefined { - const mainMatches = [...sanitized.matchAll(/@main\b/g)]; - if (mainMatches.length !== 1 || mainMatches[0]?.index == null) return undefined; - const mainIndex = mainMatches[0].index; - if (isInsideConditionalCompilation(index, mainIndex) || braceDepthAt(index, 0, mainIndex) !== 0) { - return undefined; - } - - let cursor = mainIndex + mainMatches[0][0].length; - while (true) { - cursor = skipWhitespace(sanitized, cursor); - const attribute = /^@[A-Za-z_][A-Za-z0-9_.]*/.exec(sanitized.slice(cursor)); - if (attribute) { - cursor += attribute[0].length; - cursor = skipWhitespace(sanitized, cursor); - if (sanitized[cursor] === "(") { - const closing = matchingParenthesis(sanitized, cursor); - if (closing == null) return undefined; - cursor = closing + 1; - } - continue; - } - const modifier = /^(?:public|internal|private|fileprivate|final|nonisolated)\b/.exec( - sanitized.slice(cursor), - ); - if (!modifier) break; - cursor += modifier[0].length; - } - - cursor = skipWhitespace(sanitized, cursor); - const declaration = /^struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(sanitized.slice(cursor)); - if (!declaration) return undefined; - const headerStart = cursor + declaration[0].length; - const openingBrace = sanitized.indexOf("{", headerStart); - if (openingBrace === -1) return undefined; - const header = sanitized.slice(headerStart, openingBrace); - if (/[;{}<>]/.test(header) || /\bwhere\b/.test(header)) return undefined; - const inheritance = /^\s*:\s*([A-Za-z0-9_.,\s]+)\s*$/.exec(header)?.[1]; - if (!inheritance || !inheritance.split(",").some((item) => item.trim() === "App")) { - return undefined; - } - const closingBrace = matchingBrace(sanitized, openingBrace); - if (closingBrace == null) return undefined; - if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(sanitized.slice(openingBrace, closingBrace))) { - return undefined; - } - return { - start: mainIndex, - end: closingBrace + 1, - declarationStart: cursor, - openingBrace, - closingBrace, - }; -} - interface InitializerCandidate { start: number; end: number; @@ -615,145 +561,6 @@ function initializerCandidates( return candidates; } -function bodyRange( - sanitized: string, - appType: AppStructure["appType"], - index: SwiftStructuralIndex, -): AppStructure["body"] | undefined { - const candidates: AppStructure["body"][] = []; - const pattern = /\bvar\s+body\s*:\s*some\s+Scene\b/g; - pattern.lastIndex = appType.openingBrace + 1; - let match: RegExpExecArray | null; - while ((match = pattern.exec(sanitized)) !== null && match.index < appType.closingBrace) { - if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; - const openingBrace = skipWhitespace(sanitized, match.index + match[0].length); - if (sanitized[openingBrace] !== "{") continue; - const closingBrace = matchingBrace(sanitized, openingBrace); - if (closingBrace == null || closingBrace > appType.closingBrace) continue; - const declarationLineStart = lineStart(sanitized, match.index); - if (sanitized.slice(declarationLineStart, match.index).trim() !== "") continue; - candidates.push({ - start: match.index, - end: closingBrace + 1, - declarationStart: declarationLineStart, - openingBrace, - closingBrace, - }); - pattern.lastIndex = closingBrace + 1; - } - return candidates.length === 1 ? candidates[0] : undefined; -} - -interface RootExpression { - start: number; - end: number; - containerStart: number; - modifierStarts: number[]; -} - -function identifierEnd(source: string, start: number): number | undefined { - const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(start)); - return match ? start + match[0].length : undefined; -} - -function consumeBalancedSuffix(source: string, cursor: number, limit: number): number | undefined { - if (source[cursor] === "(") { - const closing = matchingParenthesis(source, cursor); - if (closing == null || closing >= limit) return undefined; - cursor = closing + 1; - cursor = skipWhitespace(source, cursor, limit); - if (source[cursor] === "{") { - const closureEnd = matchingBrace(source, cursor); - if (closureEnd == null || closureEnd >= limit) return undefined; - cursor = closureEnd + 1; - } - return cursor; - } - if (source[cursor] === "{") { - const closureEnd = matchingBrace(source, cursor); - if (closureEnd == null || closureEnd >= limit) return undefined; - return closureEnd + 1; - } - return undefined; -} - -function rootExpression( - sanitized: string, - start: number, - end: number, - containerStart: number, -): RootExpression | undefined { - let cursor = skipWhitespace(sanitized, start, end); - const expressionStart = cursor; - let identifier = identifierEnd(sanitized, cursor); - if (identifier == null) return undefined; - cursor = identifier; - while (true) { - const beforeDot = skipWhitespace(sanitized, cursor, end); - if (sanitized[beforeDot] !== ".") break; - const memberStart = skipWhitespace(sanitized, beforeDot + 1, end); - identifier = identifierEnd(sanitized, memberStart); - if (identifier == null) return undefined; - const afterMember = skipWhitespace(sanitized, identifier, end); - if (sanitized[afterMember] === "(" || sanitized[afterMember] === "{") break; - cursor = identifier; - } - cursor = skipWhitespace(sanitized, cursor, end); - const primaryEnd = consumeBalancedSuffix(sanitized, cursor, end); - if (primaryEnd == null) return undefined; - cursor = primaryEnd; - - const modifierStarts: number[] = []; - while (true) { - cursor = skipWhitespace(sanitized, cursor, end); - if (sanitized[cursor] !== ".") break; - const modifierStart = cursor; - const nameStart = skipWhitespace(sanitized, cursor + 1, end); - const nameEnd = identifierEnd(sanitized, nameStart); - if (nameEnd == null) return undefined; - cursor = skipWhitespace(sanitized, nameEnd, end); - const suffixEnd = consumeBalancedSuffix(sanitized, cursor, end); - if (suffixEnd == null) return undefined; - modifierStarts.push(modifierStart); - cursor = suffixEnd; - } - cursor = skipWhitespace(sanitized, cursor, end); - if (cursor !== end) return undefined; - return { - start: expressionStart, - end: trimWhitespaceEnd(sanitized, expressionStart, end), - containerStart, - modifierStarts, - }; -} - -function windowGroupRoot( - sanitized: string, - body: AppStructure["body"], -): RootExpression | undefined { - let cursor = skipWhitespace(sanitized, body.openingBrace + 1, body.closingBrace); - const windowGroupStart = cursor; - if (!sanitized.slice(cursor).startsWith("WindowGroup")) return undefined; - const wordEnd = cursor + "WindowGroup".length; - if (/[A-Za-z0-9_]/.test(sanitized[wordEnd] ?? "")) return undefined; - cursor = skipWhitespace(sanitized, wordEnd, body.closingBrace); - if (sanitized[cursor] === "(") { - const closingParenthesis = matchingParenthesis(sanitized, cursor); - if (closingParenthesis == null || closingParenthesis >= body.closingBrace) return undefined; - cursor = skipWhitespace(sanitized, closingParenthesis + 1, body.closingBrace); - } - if (sanitized[cursor] !== "{") return undefined; - const groupClosingBrace = matchingBrace(sanitized, cursor); - if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; - if (skipWhitespace(sanitized, groupClosingBrace + 1, body.closingBrace) !== body.closingBrace) { - return undefined; - } - const expressionStart = skipWhitespace(sanitized, cursor + 1, groupClosingBrace); - const expressionEnd = trimWhitespaceEnd(sanitized, expressionStart, groupClosingBrace); - if (expressionStart === expressionEnd) return undefined; - return rootExpression(sanitized, expressionStart, expressionEnd, windowGroupStart); -} - /** * Proves the narrow SwiftUI starter root used by the optional AuthView * scaffold. This deliberately shares the direct-config parser's structural @@ -765,12 +572,7 @@ export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { const sanitization = sanitizeSwiftSourceWithStatus(source); if (!sanitization.complete) return false; const sanitized = sanitization.sanitizedSource; - const structuralIndex = buildSwiftStructuralIndex(sanitized); - const appType = appTypeRange(sanitized, structuralIndex); - if (!appType) return false; - const body = bodyRange(sanitized, appType, structuralIndex); - if (!body) return false; - const root = windowGroupRoot(sanitized, body); + const root = inspectSwiftUIAppRoot(sanitized)?.root; if (!root) return false; const groupOpeningBrace = sanitized.lastIndexOf("{", root.start); @@ -782,32 +584,6 @@ export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { return expression === "ContentView()" || expression === "ContentView().environment(Clerk.shared)"; } -function exactEnvironmentModifier( - sanitized: string, - root: RootExpression, -): { found: boolean; conflicting: boolean } { - let found = false; - let conflicting = false; - for (const modifierStart of root.modifierStarts) { - const remainder = sanitized.slice(modifierStart, root.end); - const name = /^\.\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(remainder)?.[1]; - if (name !== "environment") continue; - const openingParenthesis = sanitized.indexOf("(", modifierStart); - const closingParenthesis = matchingParenthesis(sanitized, openingParenthesis); - if (closingParenthesis == null || closingParenthesis > root.end) { - conflicting = true; - continue; - } - const argumentsSource = sanitized.slice(openingParenthesis + 1, closingParenthesis); - if (/^\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { - found = true; - } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { - conflicting = true; - } - } - return { found, conflicting }; -} - function exactConfigureCall( source: string, sanitized: string, @@ -916,7 +692,7 @@ function hasPreinitializationClerkSharedAccess( function environmentInsertion( source: string, newline: "\n" | "\r\n", - root: RootExpression, + root: SwiftUIRootExpression, ): AppStructure["environmentInsertion"] { const trailingLine = source.slice(root.end, lineEnd(source, root.end)); const sharesLineWithComment = /\/\*|\/\//.test(trailingLine); @@ -959,8 +735,8 @@ function parseAppStructure( } const sanitized = sanitization.sanitizedSource; const structuralIndex = buildSwiftStructuralIndex(sanitized); - const appType = appTypeRange(sanitized, structuralIndex); - if (!appType) { + const appRootInspection = inspectSwiftUIAppRootWithStatus(sanitized); + if (appRootInspection.status === "unsupported-app") { return { blocker: { code: "unsupported-app-structure", @@ -968,8 +744,7 @@ function parseAppStructure( }, }; } - const body = bodyRange(sanitized, appType, structuralIndex); - if (!body) { + if (appRootInspection.status === "unsupported-body") { return { blocker: { code: "unsupported-scene", @@ -977,8 +752,7 @@ function parseAppStructure( }, }; } - const root = windowGroupRoot(sanitized, body); - if (!root) { + if (appRootInspection.status === "unsupported-scene") { return { blocker: { code: "unsupported-scene", @@ -987,6 +761,8 @@ function parseAppStructure( }, }; } + const appRoot = appRootInspection.structure; + const { appType, body, root } = appRoot; const initializerMatches = initializerCandidates(sanitized, appType, structuralIndex); if ( @@ -1088,7 +864,7 @@ function parseAppStructure( }; } - const environment = exactEnvironmentModifier(sanitized, root); + const environment = appRoot.clerkEnvironment; if (environment.conflicting) { return { blocker: { @@ -1233,7 +1009,19 @@ async function prepareDirectConfig( ); } const projectPath = relativeIOSPath(root, absoluteProjectPath); - const inspection = await inspectIOSProject(root, { target: options.targetId }); + const inspection = await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete local Xcode container discovery could not be proven.", + ); + } if ( inspection.selection.state !== "selected" || inspection.selection.targetId !== options.targetId || diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts index 5387b09d1..e2498bc16 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -392,10 +392,12 @@ describe("missing iOS entitlements build settings", () => { const secondaryRoot = join(root, "a", "b", "c", "d"); await createIOSFixture(secondaryRoot, { includeKey: false }); const secondaryProjectPath = join(secondaryRoot, "MyApp.xcodeproj", "project.pbxproj"); - const secondaryProject = (await readFile(secondaryProjectPath, "utf8")).replaceAll( - "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", - "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", - ); + const secondaryProject = (await readFile(secondaryProjectPath, "utf8")) + .replaceAll(IOS_FIXTURE_IDS.appTarget, IOS_FIXTURE_IDS.secondTarget) + .replaceAll( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", + ); await writeFile(secondaryProjectPath, secondaryProject); expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts index 9844ab2ea..1c8ce3f0e 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -871,7 +871,10 @@ async function inspectSelectedTarget( projectPath: string, targetId: string, ): Promise { - const inspection = await inspectIOSProject(root, { target: targetId }); + const inspection = await inspectIOSProject(root, { + target: targetId, + exhaustiveContainerDiscovery: true, + }); if ( inspection.selection.state !== "selected" || inspection.selection.targetId !== targetId || diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 1a9bc588e..f87ae09d2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1368,9 +1368,16 @@ let package = Package( await Bun.write(join(workspace, "contents.xcworkspacedata"), "not an Xcode workspace\n"); const result = await inspectWorkspace(root, workspace); + const inspection = await inspectIOSProject(root, { exhaustiveContainerDiscovery: true }); + const memberships = await inspectIOSSourceMembership(root); expect(result.complete).toBe(false); expect(result.localProjectPaths).toEqual([]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-container-discovery" }), + ); + expect(memberships.length).toBeGreaterThan(0); + expect(memberships.every((membership) => !membership.complete)).toBe(true); }); test.each(["absolute", "parent-relative", "symlink-escape"] as const)( diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 14f777d82..3177bc4cc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -93,7 +93,9 @@ function emptySwiftInspection() { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], openURLHandlers: [], @@ -1285,17 +1287,32 @@ export async function inspectIOSProject( const discovered = await discoverIOSContainers(invocationPath, { exhaustive: options.exhaustiveContainerDiscovery === true, }); + let discoveryComplete = discovered.complete; const projectPaths = new Set(discovered.projectPaths); const workspaces = []; for (const workspacePath of discovered.workspacePaths) { const workspace = await inspectWorkspace(root, workspacePath); + discoveryComplete &&= workspace.complete; workspaces.push(workspace.inspection); for (const projectPath of workspace.localProjectPaths) projectPaths.add(projectPath); } const referencedProjects = await discoverReferencedIOSProjects(root, projectPaths); for (const projectPath of referencedProjects.projectPaths) projectPaths.add(projectPath); + discoveryComplete &&= referencedProjects.complete; + + if (options.exhaustiveContainerDiscovery === true && !discoveryComplete) { + diagnostics.push({ + code: "xcode.incomplete-container-discovery", + severity: "warning", + message: + "Xcode container discovery was incomplete, so Clerk could not prove that all local application targets were inspected.", + remedy: + "Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", + evidence: [{ path: "." }], + }); + } if (projectPaths.size === 0) { diagnostics.push({ @@ -1319,7 +1336,7 @@ export async function inspectIOSProject( sourceMemberships.push(...(parsed.sourceMemberships ?? [])); diagnostics.push(...parsed.diagnostics); } - if (options.exhaustiveContainerDiscovery === true && !discovered.complete) { + if (options.exhaustiveContainerDiscovery === true && !discoveryComplete) { for (const membership of sourceMemberships) membership.complete = false; } if (!referencedProjects.complete) { @@ -1381,6 +1398,14 @@ export async function inspectIOSProject( return result; } +export function hasIncompleteIOSContainerDiscovery( + inspection: IOSProjectInspectionResult, +): boolean { + return inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.incomplete-container-discovery", + ); +} + /** * Returns the exact source-membership result used by the iOS semantic * inspector without adding source paths to the serializable inspection JSON. diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index e86b08dc7..a55453ef1 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -4,7 +4,7 @@ import { isDeepStrictEqual } from "node:util"; import { dirname, isAbsolute, resolve } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import semver from "semver"; -import { inspectIOSProject } from "./inspect.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; import { @@ -56,6 +56,7 @@ export type IOSSDKInstallBlockerCode = | "malformed-project" | "target-not-found" | "ambiguous-target" + | "incomplete-container-discovery" | "ambiguous-package" | "duplicate-package" | "unattributed-product" @@ -831,9 +832,22 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("iOS local setup lifecycle", () => { + useCaptureLog(); + + test("does not ask about Apple after an explicitly requested AuthView plan is blocked", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-plan-blocked-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + let applePromptCount = 0; + + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: true, + resolveNativeAppleRequest: async () => { + applePromptCount += 1; + return true; + }, + }); + + expect(proposal.inspectedPrebuiltAuthPlan?.status).toBe("blocked"); + expect(applePromptCount).toBe(0); + expect(proposal.nativeAppleRequested).toBe(false); + }); + + test("uses the same read-only proposal for preview and apply", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const initialBytes = await treeDigest(root); + + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + + expect(await treeDigest(root)).toEqual(initialBytes); + const dryRun = createIOSDryRunOutput(proposal.inspection, proposal.setupPlan, { + associatedDomainPlan: proposal.plannedAssociatedDomain, + nativeReadiness: proposal.nativeReadiness, + }); + expect(dryRun.plan).toBe(proposal.setupPlan); + expect(dryRun.nativeReadiness).toBe(proposal.nativeReadiness); + expect( + proposal.setupPlan.steps + .filter((step) => step.status === "required" && step.automatable) + .map((step) => step.id), + ).toEqual( + expect.arrayContaining([ + "install-clerk-sdk", + "configure-publishable-key", + "inject-clerk-environment", + "add-associated-domain", + ]), + ); + + const approved = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + expect(approved.setupPlan).toEqual(proposal.setupPlan); + expect(await treeDigest(root)).toEqual(initialBytes); + + await applyIOSPlannedLocalSetup(approved, publishableKey); + const appliedBytes = await treeDigest(root); + expect(appliedBytes).not.toEqual(initialBytes); + + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + await applyIOSPlannedLocalSetup(rerun, publishableKey); + expect(await treeDigest(root)).toEqual(appliedBytes); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/local-plan.ts b/packages/cli-core/src/commands/init/ios/local-plan.ts new file mode 100644 index 000000000..c3b18648e --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/local-plan.ts @@ -0,0 +1,331 @@ +import type { IOSProjectInspectionResult, IOSSetupPlan } from "./types.ts"; +import type { IOSAppTarget } from "./types.ts"; +import { + clerkKitUIInstallDecision, + hasSupportedIOSCustomConfigure, + shouldPlanIOSDirectConfig, +} from "./products.ts"; +import { planIOSPrebuiltAuth, type IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; +import { planIOSDirectConfig, type IOSDirectConfigPlan } from "./direct-config.ts"; +import { planIOSAssociatedDomain, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { planIOSAppleEntitlement, type IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; +import { planIOSSDKInstall, type IOSSDKInstallPlan } from "./install-sdk.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { + buildIOSNativeReadinessAudit, + suggestAppIdPrefixFromDevelopmentTeam, + type IOSNativeReadinessAudit, + type IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; + +type ProductDecision = ReturnType; + +export interface IOSLocalSetupContext { + inspection: IOSProjectInspectionResult; + selectedTarget?: IOSAppTarget; + productDecision?: ProductDecision; +} + +export interface BuildIOSLocalSetupProposalOptions { + root: string; + allowDirty: boolean; + /** Explicit AuthView choice. Undefined allows the caller to resolve a human choice. */ + prebuiltAuthUI?: boolean; + /** Explicit native Apple choice. Undefined allows the caller to resolve a human choice. */ + signInWithApple?: boolean; + resolvePrebuiltAuthRequest?: (options: { + targetName: string; + plan: IOSPrebuiltAuthPlan; + }) => Promise; + resolveNativeAppleRequest?: (options: { bundleIdentifier: string }) => Promise; +} + +/** + * One credential-free, mutation-free proposal shared by dry-run and apply. + * Candidate bytes and prepared mutations never enter this structure. + */ +export interface IOSLocalSetupProposal { + inspection: IOSProjectInspectionResult; + selectedTarget?: IOSAppTarget; + productDecision?: ProductDecision; + setupPlan: IOSSetupPlan; + nativeReadiness: IOSNativeReadinessAudit; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + inspectedPrebuiltAuthPlan?: IOSPrebuiltAuthPlan; + prebuiltAuthPlanForSetup?: IOSPrebuiltAuthPlan; + prebuiltAuthPlan?: IOSPrebuiltAuthPlan; + prebuiltRuntimeBlockers: string[]; + prebuiltAuthRequested: boolean; + prebuiltAuthActive: boolean; + installPlan?: IOSSDKInstallPlan; + sdkInstallPlan?: IOSSDKInstallPlan; + reviewOnlyUnattributedInstall: boolean; + directConfigPlan?: IOSDirectConfigPlan; + plannedAssociatedDomain?: IOSAssociatedDomainPlan; + associatedDomainPlan?: IOSAssociatedDomainPlan; + inspectedAppleEntitlementPlan?: IOSAppleEntitlementPlan; + appleEntitlementPlan?: IOSAppleEntitlementPlan; + prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; + nativeAppleRequested: boolean; + hasCustomConfigure: boolean; + hasSupportedCustomConfigure: boolean; +} + +export function createIOSLocalSetupContext( + inspection: IOSProjectInspectionResult, +): IOSLocalSetupContext { + const selection = inspection.selection; + if (selection.state !== "selected") return { inspection }; + const selectedTarget = inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); + return { + inspection, + selectedTarget, + productDecision: selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined, + }; +} + +/** Keep legacy, fully linked product graphs review-only while preserving every other SDK blocker. */ +export function normalizeIOSSDKInstallPlanForSetup(options: { + installPlan: IOSSDKInstallPlan; + selectedTarget: IOSAppTarget; + prebuiltAuthActive: boolean; +}): { + sdkInstallPlan?: IOSSDKInstallPlan; + reviewOnlyUnattributedInstall: boolean; +} { + const { installPlan, selectedTarget, prebuiltAuthActive } = options; + const reviewOnlyUnattributedInstall = + !prebuiltAuthActive && + installPlan.requirePrebuiltAuthCompatibility !== true && + installPlan.status === "blocked" && + installPlan.blockers.length > 0 && + installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && + installPlan.products.every((product) => + product === "ClerkKit" + ? selectedTarget.packages.clerkKit === "linked" + : selectedTarget.packages.clerkKitUI === "linked", + ); + return { + sdkInstallPlan: reviewOnlyUnattributedInstall ? undefined : installPlan, + reviewOnlyUnattributedInstall, + }; +} + +export function planIOSPrebuiltAuthRuntimeBlockers( + inspection: IOSProjectInspectionResult, + directConfigPlan: IOSDirectConfigPlan | undefined, +): string[] { + const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const directConfigurationReady = + directConfigPlan?.status === "ready" && configureStep?.automatable === true; + const directEnvironmentReady = + directConfigPlan?.status === "ready" && + (directConfigPlan.changes?.environment === "insert" || + directConfigPlan.changes?.environment === "satisfied"); + const blockers: string[] = []; + + if (configureStep?.status !== "satisfied" && !directConfigurationReady) { + blockers.push( + "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", + ); + } + if (environmentStep?.status !== "satisfied" && !directEnvironmentReady) { + blockers.push( + "Clerk.shared is not proven in the shipping SwiftUI root environment, and the existing runtime abstraction cannot be rewritten safely.", + ); + } + + return blockers; +} + +export async function buildIOSLocalSetupProposal( + context: IOSLocalSetupContext, + options: BuildIOSLocalSetupProposalOptions, +): Promise { + const { inspection, selectedTarget, productDecision } = context; + const selection = inspection.selection; + if (selection.state !== "selected" || !selectedTarget || !productDecision) { + const setupPlan = buildIOSSetupPlan(inspection, { + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + return { + inspection, + selectedTarget, + productDecision, + setupPlan, + nativeReadiness: buildIOSNativeReadinessAudit(inspection), + prebuiltAuthRequested: options.prebuiltAuthUI === true, + prebuiltAuthActive: false, + prebuiltRuntimeBlockers: [], + reviewOnlyUnattributedInstall: false, + nativeAppleRequested: options.signInWithApple === true, + hasCustomConfigure: false, + hasSupportedCustomConfigure: false, + }; + } + + const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }); + let prebuiltAuthRequested = options.prebuiltAuthUI === true; + if ( + !prebuiltAuthRequested && + options.prebuiltAuthUI == null && + inspectedPrebuiltAuthPlan.status === "ready" && + options.resolvePrebuiltAuthRequest + ) { + prebuiltAuthRequested = await options.resolvePrebuiltAuthRequest({ + targetName: selection.targetName, + plan: inspectedPrebuiltAuthPlan, + }); + } + const prebuiltAuthActive = + inspectedPrebuiltAuthPlan.status !== "blocked" && + (prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"); + + const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; + const installPlan = await planIOSSDKInstall({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + includeClerkKitUI, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, + }); + + const hasCustomConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "custom", + ); + const hasSupportedCustomConfigure = hasSupportedIOSCustomConfigure(selectedTarget); + const directConfigPlan = shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ) + ? await planIOSDirectConfig({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }) + : undefined; + + const prebuiltRuntimeBlockers = prebuiltAuthActive + ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) + : []; + const prebuiltAuthPlanForSetup = + prebuiltRuntimeBlockers.length > 0 + ? { + ...inspectedPrebuiltAuthPlan, + status: "blocked" as const, + actions: [], + blockers: [ + ...inspectedPrebuiltAuthPlan.blockers, + { + code: "runtime-prerequisites" as const, + message: prebuiltRuntimeBlockers.join(" "), + }, + ], + } + : inspectedPrebuiltAuthPlan; + const prebuiltAuthPlan = prebuiltAuthActive ? prebuiltAuthPlanForSetup : undefined; + + const plannedAssociatedDomain = await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, + allowMissingEntitlementsCreation: true, + }); + const associatedDomainPlan = + plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { + associatedDomainPlan: plannedAssociatedDomain, + }); + + const hasLocalAppleEntitlement = selectedTarget.configurations.some( + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", + ); + let nativeAppleRequested = options.signInWithApple === true; + if ( + !nativeAppleRequested && + options.signInWithApple == null && + options.resolveNativeAppleRequest && + !(prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") && + nativeReadiness.target.status === "selected" && + nativeReadiness.target.bundleIdentifier.status === "resolved" + ) { + nativeAppleRequested = await options.resolveNativeAppleRequest({ + bundleIdentifier: nativeReadiness.target.bundleIdentifier.value, + }); + } + const inspectedAppleEntitlementPlan = + nativeAppleRequested || hasLocalAppleEntitlement || prebuiltAuthActive + ? await planIOSAppleEntitlement({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowMissingEntitlementsCreation: true, + }) + : undefined; + const appleEntitlementPlan = nativeAppleRequested + ? inspectedAppleEntitlementPlan + : hasLocalAppleEntitlement && inspectedAppleEntitlementPlan?.status === "blocked" + ? inspectedAppleEntitlementPlan + : inspectedAppleEntitlementPlan?.status === "satisfied" + ? inspectedAppleEntitlementPlan + : undefined; + const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive + ? inspectedAppleEntitlementPlan + : undefined; + + const { sdkInstallPlan, reviewOnlyUnattributedInstall } = normalizeIOSSDKInstallPlanForSetup({ + installPlan, + selectedTarget, + prebuiltAuthActive, + }); + const setupPlan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + directConfigPlan, + associatedDomainPlan: plannedAssociatedDomain, + appleEntitlementPlan, + prebuiltAuthPlan: prebuiltAuthPlanForSetup, + prebuiltAuthSelected: prebuiltAuthRequested, + }); + const unverifiedAppIdPrefixSuggestion = suggestAppIdPrefixFromDevelopmentTeam(selectedTarget); + + return { + inspection, + selectedTarget, + productDecision, + setupPlan, + nativeReadiness, + ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), + inspectedPrebuiltAuthPlan, + prebuiltAuthPlanForSetup, + prebuiltAuthPlan, + prebuiltRuntimeBlockers, + prebuiltAuthRequested, + prebuiltAuthActive, + installPlan, + sdkInstallPlan, + reviewOnlyUnattributedInstall, + directConfigPlan, + plannedAssociatedDomain, + associatedDomainPlan, + inspectedAppleEntitlementPlan, + appleEntitlementPlan, + prebuiltAuthAppleEntitlementPlan, + nativeAppleRequested, + hasCustomConfigure, + hasSupportedCustomConfigure, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts new file mode 100644 index 000000000..ff7ffd9b2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -0,0 +1,378 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { + buildIOSNativeReadinessAudit, + IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + suggestAppIdPrefixFromDevelopmentTeam, +} from "./native-readiness.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function inspectionFor( + options: Parameters[1] = {}, + target?: string, +) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-native-readiness-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + return inspectIOSProject(root, { target }); +} + +async function inspectionWithInlineKey(options: Parameters[1] = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-native-readiness-inline-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { ...options, complete: false, includeKey: false }); + const encodedHost = Buffer.from("native.clerk.example$").toString("base64"); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "pk_test_${encodedHost}") } + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + return inspectIOSProject(root, { target: "MyApp" }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSNativeReadinessAudit", () => { + test("reports a redacted selected-target identity and the exact authenticated PLAPI bridge", async () => { + const inspection = await inspectionFor({ complete: true }); + const selected = inspection.appTargets[0]!; + for (const configuration of selected.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "DEVELOPMENT_TEAM_MUST_NOT_ESCAPE", + evidence: [], + }; + if (configuration.entitlements) { + configuration.entitlements.teamIdentifier = "ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"; + } + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: { + status: "selected", + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: "LEGACY1234", + }, + }, + associatedDomain: { + status: "blocked", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [ + { + code: "expected-domain-unavailable", + message: + "A proven local publishable key is required to derive the webcredentials domain.", + }, + ], + }, + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }); + expect(audit.associatedDomain.expectedDomain).toBeUndefined(); + expect(audit.remote.requirement).toEqual({ + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], + }); + expect(JSON.stringify(audit)).not.toContain("DEVELOPMENT_TEAM_MUST_NOT_ESCAPE"); + expect(JSON.stringify(audit)).not.toContain("ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"); + }); + + test("offers one unanimous Xcode Development Team only as an unverified suggestion", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + expect(JSON.stringify(buildIOSNativeReadinessAudit(inspection))).not.toContain("ABCDE12345"); + }); + + test("withholds the Xcode Development Team suggestion unless every configuration agrees", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.developmentTeam = { + state: "resolved", + value: "ZZZZZ99999", + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { state: "missing", evidence: [] }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { + state: "unresolved", + raw: "$(APPLE_TEAM)", + missingVariables: ["APPLE_TEAM"], + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + for (const configuration of target.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "NOT-A-TEAM", + evidence: [], + }; + } + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + }); + + test("requires the bare domain when only Apple's developer-mode entry is present", async () => { + const inspection = await inspectionWithInlineKey(); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "required", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: true, + blockers: [], + }); + }); + + test("recognizes the exact bare domain as locally satisfied", async () => { + const inspection = await inspectionWithInlineKey(); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["webcredentials:native.clerk.example"]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "satisfied", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [], + }); + }); + + test("does not satisfy readiness with a differently cased service token", async () => { + const inspection = await inspectionWithInlineKey(); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["WEBCREDENTIALS:native.clerk.example"]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toMatchObject({ + status: "required", + expectedDomain: "webcredentials:native.clerk.example", + }); + }); + + test("blocks automation when configurations have mixed entitlements evidence", async () => { + const inspection = await inspectionWithInlineKey(); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = undefined; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.status).toBe("required"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.files).toEqual(["MyApp/MyApp.entitlements"]); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "missing-or-unreadable-entitlements" }), + ); + }); + + test("carries a strict Associated Domains blocker into native readiness", async () => { + const inspection = await inspectionFor({ complete: true }); + const associatedDomainPlan: IOSAssociatedDomainPlan = { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: "blocked", + root: inspection.root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + requiresPublishableKey: false, + files: [], + actions: [], + blockers: [{ code: "generated-project", message: "Update the project source definition." }], + }; + + const audit = buildIOSNativeReadinessAudit(inspection, { associatedDomainPlan }); + + expect(audit.associatedDomain).toMatchObject({ status: "review", automatable: false }); + expect(audit.associatedDomain.blockers).toContainEqual({ + code: "manual-review-required", + message: "Update the project source definition.", + }); + }); + + test("preserves all distinct existing XML entitlements routes", async () => { + const inspection = await inspectionWithInlineKey(); + const target = inspection.appTargets[0]!; + const release = target.configurations[1]!; + release.entitlements = { + ...release.entitlements!, + path: "MyApp/MyApp-Release.entitlements", + associatedDomains: [], + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp-Release.entitlements", "MyApp/MyApp.entitlements"], + blockers: [], + }); + }); + + test("does not claim a single bundle identifier or App ID Prefix when they conflict", async () => { + const inspection = await inspectionFor({ complete: true, conflictingBundle: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = { + ...target.configurations[1]!.entitlements!, + literalAppIdentifierPrefix: "OTHERPREFIX", + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + bundleIdentifier: { + status: "conflicting", + candidates: ["com.example.MyApp", "com.example.MyApp.release"], + }, + appIdPrefix: { + status: "conflicting", + source: "literal-entitlements", + candidates: ["LEGACY1234", "OTHERPREFIX"], + }, + }); + }); + + test("treats case-only Bundle ID variants as one identity and preserves the first spelling", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + target.configurations[0]!.bundleIdentifier = { + state: "resolved", + value: "com.Example.MyApp", + evidence: [], + }; + target.configurations[1]!.bundleIdentifier = { + state: "resolved", + value: "COM.EXAMPLE.MYAPP", + evidence: [], + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + bundleIdentifier: { status: "resolved", value: "com.Example.MyApp" }, + }); + }); + + test("preserves a partial App ID Prefix candidate when one selected configuration lacks it", async () => { + const inspection = await inspectionFor({ complete: true }); + const releaseEntitlements = inspection.appTargets[0]!.configurations[1]!.entitlements!; + delete releaseEntitlements.literalAppIdentifierPrefix; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + appIdPrefix: { + status: "missing", + source: "literal-entitlements", + candidates: ["LEGACY1234"], + }, + }); + }); + + test("blocks identity and entitlement routing when target selection is ambiguous", async () => { + const inspection = await inspectionFor({ secondTarget: true }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toEqual({ status: "blocked", reason: "target-not-selected" }); + expect(audit.associatedDomain).toMatchObject({ + status: "blocked", + files: [], + automatable: false, + }); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "target-not-selected" }), + ); + }); + + test("does not invent a domain without redacted publishable-key metadata", async () => { + const inspection = await inspectionFor({ complete: false, includeKey: false }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.expectedDomain).toBeUndefined(); + expect(audit.associatedDomain.status).toBe("blocked"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "expected-domain-unavailable" }), + ); + }); + + test("never copies an unexpected raw publishable-key property", async () => { + const inspection = await inspectionFor({ complete: true }); + const key = `pk_test_${Buffer.from("must-not-escape.example$").toString("base64")}`; + (inspection.localPublishableKey as unknown as Record).publishableKey = key; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(JSON.stringify(audit)).not.toContain(key); + expect(JSON.stringify(audit)).not.toContain("publishableKey"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts new file mode 100644 index 000000000..852da0c9c --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -0,0 +1,364 @@ +import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; +import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStepStatus } from "./types.ts"; + +export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], +} as const; + +export type IOSNativeReadinessBundleIdentifier = + | { status: "resolved"; value: string } + | { status: "missing" } + | { status: "unresolved" } + | { status: "conflicting"; candidates: string[] }; + +export type IOSNativeReadinessAppIdPrefix = + | { status: "resolved"; source: "literal-entitlements"; value: string } + | { + status: "missing"; + source: "literal-entitlements"; + /** Literal values observed in only part of the selected target's configuration set. */ + candidates?: string[]; + } + | { + status: "conflicting"; + source: "literal-entitlements"; + candidates: string[]; + }; + +/** + * A human-only convenience value from Xcode signing configuration. This is + * never treated as proven App ID Prefix evidence because legacy Apple + * accounts can use a prefix that differs from DEVELOPMENT_TEAM. + */ +export type IOSUnverifiedAppIdPrefixSuggestion = { + source: "xcode-development-team"; + value: string; +}; + +export type IOSNativeReadinessTarget = + | { + status: "selected"; + projectPath: string; + targetId: string; + targetName: string; + bundleIdentifier: IOSNativeReadinessBundleIdentifier; + appIdPrefix: IOSNativeReadinessAppIdPrefix; + } + | { + status: "blocked"; + reason: "target-not-selected" | "selected-target-not-found"; + }; + +export type IOSAssociatedDomainAutomationBlockerCode = + | "target-not-selected" + | "expected-domain-unavailable" + | "manual-review-required" + | "generated-project" + | "missing-build-configurations" + | "unresolved-entitlements-path" + | "missing-or-unreadable-entitlements" + | "unresolved-associated-domains"; + +export interface IOSAssociatedDomainAutomationBlocker { + code: IOSAssociatedDomainAutomationBlockerCode; + message: string; +} + +export interface IOSAssociatedDomainReadiness { + /** The local status from the canonical iOS setup plan. */ + status: IOSSetupStepStatus; + /** Exact entitlement value derived from redacted publishable-key metadata. */ + expectedDomain?: string; + /** Existing, inspected XML entitlements files owned by the selected target. */ + files: string[]; + /** True only when a future writer has a complete, unambiguous local route. */ + automatable: boolean; + blockers: IOSAssociatedDomainAutomationBlocker[]; +} + +export interface IOSNativeReadinessAudit { + schemaVersion: 1; + kind: "clerk-ios-native-readiness"; + root: string; + target: IOSNativeReadinessTarget; + associatedDomain: IOSAssociatedDomainReadiness; + remote: { + status: "not-inspected"; + reason: "dry-run-does-not-read-remote-state"; + requirement: typeof IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT; + }; +} + +export interface BuildIOSNativeReadinessAuditOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; +} + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +function resolvedBundleIdentifiers(target: IOSAppTarget): string[] { + const candidatesByIdentity = new Map(); + for (const configuration of target.configurations) { + const value = configuration.bundleIdentifier; + if (value.state !== "resolved") continue; + const identity = normalizeBundleIdentifierIdentity(value.value); + if (!candidatesByIdentity.has(identity)) candidatesByIdentity.set(identity, value.value); + } + return [...candidatesByIdentity.values()].sort(); +} + +export function suggestAppIdPrefixFromDevelopmentTeam( + target: IOSAppTarget, +): IOSUnverifiedAppIdPrefixSuggestion | undefined { + if (target.configurations.length === 0) return undefined; + + const values = target.configurations.map((configuration) => configuration.developmentTeam); + if (values.some((value) => value.state !== "resolved")) return undefined; + + const candidates = [ + ...new Set(values.map((value) => (value.state === "resolved" ? value.value.trim() : ""))), + ]; + if (candidates.length !== 1 || !/^[A-Z0-9]{10}$/.test(candidates[0]!)) return undefined; + + return { source: "xcode-development-team", value: candidates[0]! }; +} + +function bundleIdentifier(target: IOSAppTarget): IOSNativeReadinessBundleIdentifier { + if (target.configurations.length === 0) return { status: "missing" }; + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "missing", + ) + ) { + return { status: "missing" }; + } + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "unresolved", + ) + ) { + return { status: "unresolved" }; + } + + const candidates = resolvedBundleIdentifiers(target); + if (candidates.length === 1) return { status: "resolved", value: candidates[0]! }; + if (candidates.length === 0) return { status: "missing" }; + return { status: "conflicting", candidates }; +} + +function appIdPrefix(target: IOSAppTarget): IOSNativeReadinessAppIdPrefix { + const candidates = [ + ...new Set( + target.configurations.flatMap((configuration) => { + const value = configuration.entitlements?.literalAppIdentifierPrefix; + return value == null ? [] : [value]; + }), + ), + ].sort(); + + if ( + candidates.length === 1 && + target.configurations.length > 0 && + target.configurations.every( + (configuration) => configuration.entitlements?.literalAppIdentifierPrefix === candidates[0], + ) + ) { + return { status: "resolved", source: "literal-entitlements", value: candidates[0]! }; + } + if (candidates.length > 1) { + return { status: "conflicting", source: "literal-entitlements", candidates }; + } + return { status: "missing", source: "literal-entitlements", candidates }; +} + +function targetIdentity( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, +): IOSNativeReadinessTarget { + if (inspection.selection.state !== "selected") { + return { status: "blocked", reason: "target-not-selected" }; + } + if (!target) return { status: "blocked", reason: "selected-target-not-found" }; + + return { + status: "selected", + projectPath: target.projectPath, + targetId: target.id, + targetName: target.name, + bundleIdentifier: bundleIdentifier(target), + appIdPrefix: appIdPrefix(target), + }; +} + +function associatedDomainReadiness( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, + associatedDomainPlan: IOSAssociatedDomainPlan | undefined, +): IOSAssociatedDomainReadiness { + const plan = buildIOSSetupPlan(inspection, { associatedDomainPlan }); + const planStep = plan.steps.find((step) => step.id === "add-associated-domain"); + const host = + inspection.localPublishableKey.state === "valid" + ? inspection.localPublishableKey.frontendApiHost + : undefined; + const expectedDomain = host ? `webcredentials:${host}` : undefined; + const files = + associatedDomainPlan?.files.map((file) => file.path) ?? + (target + ? [ + ...new Set( + target.configurations.flatMap((configuration) => + configuration.entitlements ? [configuration.entitlements.path] : [], + ), + ), + ].sort() + : []); + const everyConfigurationHasExactDomain = + expectedDomain != null && + target != null && + target.configurations.length > 0 && + target.configurations.every((configuration) => + configuration.entitlements?.associatedDomains.some((domain) => + associatedDomainMatches(domain, expectedDomain), + ), + ); + // The legacy planner accepts Apple's ?mode=developer suffix. Native setup + // automation intentionally requires the bare production-capable entry. + const status = associatedDomainPlan + ? associatedDomainPlan.status === "ready" + ? "required" + : associatedDomainPlan.status === "satisfied" + ? "satisfied" + : (planStep?.status ?? "blocked") + : planStep?.status === "satisfied" && !everyConfigurationHasExactDomain + ? "required" + : (planStep?.status ?? "blocked"); + const blockers: IOSAssociatedDomainAutomationBlocker[] = []; + const strictPlanOwnsLocalReadiness = + associatedDomainPlan?.status === "ready" || associatedDomainPlan?.status === "satisfied"; + + if (!target) { + blockers.push({ + code: "target-not-selected", + message: "Select exactly one iOS application target before editing entitlements.", + }); + } else if (!strictPlanOwnsLocalReadiness) { + if (target.configurations.length === 0) { + blockers.push({ + code: "missing-build-configurations", + message: "The selected target has no inspected build configurations.", + }); + } + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "resolved", + ) + ) { + blockers.push({ + code: "unresolved-entitlements-path", + message: "Resolve CODE_SIGN_ENTITLEMENTS for every selected-target configuration.", + }); + } + if (target.configurations.some((configuration) => configuration.entitlements == null)) { + blockers.push({ + code: "missing-or-unreadable-entitlements", + message: "Every selected-target configuration must use an existing XML entitlements file.", + }); + } + if ( + target.configurations.some( + (configuration) => + (configuration.entitlements?.unresolvedAssociatedDomains.length ?? 0) > 0, + ) + ) { + blockers.push({ + code: "unresolved-associated-domains", + message: "Resolve existing associated-domain build variables before editing entitlements.", + }); + } + } + + if (!expectedDomain && associatedDomainPlan?.requiresPublishableKey !== true) { + blockers.push({ + code: "expected-domain-unavailable", + message: "A proven local publishable key is required to derive the webcredentials domain.", + }); + } + if (inspection.generatedProject !== null) { + blockers.push({ + code: "generated-project", + message: `The Xcode project is owned by ${inspection.generatedProject}; update its source definition instead.`, + }); + } + if (status === "review") { + blockers.push({ + code: "manual-review-required", + message: "The canonical iOS setup plan requires review before this domain can be edited.", + }); + } + + const strictPlanBlockers = + associatedDomainPlan?.blockers.map((item) => ({ + code: "manual-review-required" as const, + message: item.message, + })) ?? []; + const plannedExpectedDomain = + associatedDomainPlan?.requiresPublishableKey === true + ? undefined + : (associatedDomainPlan?.expectedDomain ?? expectedDomain); + return { + status, + expectedDomain: plannedExpectedDomain, + files, + automatable: + associatedDomainPlan != null + ? associatedDomainPlan.status === "ready" && strictPlanBlockers.length === 0 + : status === "required" && blockers.length === 0, + blockers: [...strictPlanBlockers, ...blockers], + }; +} + +/** + * Builds a synchronous, serializable readiness snapshot without authentication, + * network access, or filesystem writes. Publishable-key values are never copied. + */ +export function buildIOSNativeReadinessAudit( + inspection: IOSProjectInspectionResult, + options: BuildIOSNativeReadinessAuditOptions = {}, +): IOSNativeReadinessAudit { + const target = selectedTarget(inspection); + return { + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: targetIdentity(inspection, target), + associatedDomain: associatedDomainReadiness(inspection, target, options.associatedDomainPlan), + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts new file mode 100644 index 000000000..efbc3f316 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -0,0 +1,139 @@ +import type { IOSProjectInspectionResult, IOSSetupPlan, IOSSetupStepStatus } from "./types.ts"; +import { buildIOSNativeReadinessAudit, type IOSNativeReadinessAudit } from "./native-readiness.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { hasSupportedIOSCustomConfigure } from "./products.ts"; + +const STATUS_MARKER: Record = { + satisfied: "✓", + required: "○", + review: "!", + blocked: "×", +}; + +export interface IOSDryRunOutput { + schemaVersion: 1; + mode: "read-only"; + status: IOSSetupPlan["status"]; + inspection: IOSProjectInspectionResult; + plan: IOSSetupPlan; + nativeReadiness: IOSNativeReadinessAudit; +} + +export interface IOSOutputOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; + /** Exact readiness audit from the shared local setup proposal. */ + nativeReadiness?: IOSNativeReadinessAudit; +} + +export function createIOSDryRunOutput( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): IOSDryRunOutput { + return { + schemaVersion: 1, + mode: "read-only", + status: plan.status, + inspection, + plan, + nativeReadiness: options.nativeReadiness ?? buildIOSNativeReadinessAudit(inspection, options), + }; +} + +export function formatIOSSetupPlan( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): string { + const lines = ["", "iOS setup plan (read-only)", ` Root: ${inspection.root}`]; + + if (inspection.selection.state === "selected") { + lines.push( + ` Target: ${inspection.selection.targetName} (${inspection.selection.projectPath})`, + ); + } else if (inspection.selection.state === "ambiguous") { + lines.push(" Targets:"); + for (const candidate of inspection.selection.candidates) { + lines.push( + ` - ${candidate.targetName} [${candidate.targetId}] in ${candidate.projectPath}`, + ); + } + } + + const selection = inspection.selection; + const selected = + selection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + if (selected) { + const bundles = [ + ...new Set( + selected.configurations.flatMap((configuration) => + configuration.bundleIdentifier.state === "resolved" + ? [configuration.bundleIdentifier.value] + : [], + ), + ), + ]; + if (bundles.length > 0) lines.push(` Bundle ID: ${bundles.join(", ")}`); + lines.push( + ` ClerkKit: ${selected.packages.clerkKit}; ClerkKitUI: ${selected.packages.clerkKitUI}`, + ); + } + const localPublishableKey = inspection.localPublishableKey; + if (localPublishableKey.state === "valid") { + lines.push( + ` Publishable key: found (${localPublishableKey.instanceType}; ${localPublishableKey.frontendApiHost})`, + ); + } else if (selected && hasSupportedIOSCustomConfigure(selected)) { + lines.push(" Publishable key: custom source (value not inspected)"); + } else { + const keyStatus = + localPublishableKey.state === "invalid" + ? "invalid inline key" + : localPublishableKey.state === "unproven" + ? "configuration needs review (value not inspected)" + : "not found"; + lines.push(` Publishable key: ${keyStatus}`); + } + + lines.push(""); + for (const item of plan.steps) { + lines.push(` ${STATUS_MARKER[item.status]} [${item.status}] ${item.title}`); + lines.push(` ${item.description}`); + if (item.automatable) lines.push(" `clerk init` can apply this step."); + for (const link of item.links ?? []) lines.push(` ${link.url}`); + } + + if (plan.diagnostics.length > 0) { + lines.push("", " Diagnostics:"); + for (const diagnostic of plan.diagnostics) { + lines.push(` - [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`); + if (diagnostic.remedy) lines.push(` ${diagnostic.remedy}`); + } + } + + const nativeReadiness = + options.nativeReadiness ?? buildIOSNativeReadinessAudit(inspection, options); + lines.push("", " Native iOS readiness:"); + lines.push( + ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, + ); + if (!nativeReadiness.associatedDomain.automatable) { + for (const blocker of nativeReadiness.associatedDomain.blockers) { + lines.push(` ${blocker.message}`); + } + } + lines.push( + " - Native API and Dashboard iOS registration: not inspected during this local-only dry-run. Regular `clerk init` audits and safely reconciles both on the linked development instance after authentication.", + ); + + lines.push( + "", + " No files, Xcode settings, Clerk applications, or remote resources were changed.", + ); + return lines.join("\n"); +} diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts new file mode 100644 index 000000000..e08a676c2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -0,0 +1,859 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { planIOSDirectConfig } from "./direct-config.ts"; +import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { formatIOSSetupPlan } from "./output.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { createIOSFixture } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function planFor(options: Parameters[1] = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + const inspection = await inspectIOSProject(root); + return buildIOSSetupPlan(inspection); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSSetupPlan", () => { + test("returns stable ordered steps while preserving a custom project key source", async () => { + const plan = await planFor({ complete: true }); + + expect(plan.steps.map((step) => step.id)).toEqual([ + "select-target", + "install-clerk-sdk", + "configure-publishable-key", + "inject-clerk-environment", + "register-native-application", + "add-associated-domain", + "add-authentication-flow", + "verify-integration", + ]); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.filter((step) => step.automatable)).toEqual([]); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep?.status).toBe("satisfied"); + expect(configureStep?.description).toContain("custom publishable-key source"); + expect(configureStep?.description).toContain("value is not inspected"); + const domainStep = plan.steps.find((step) => step.id === "add-associated-domain"); + expect(domainStep?.status).toBe("blocked"); + expect(domainStep?.description).toContain("valid local publishable key is needed"); + expect(plan.steps.find((step) => step.id === "register-native-application")?.status).toBe( + "review", + ); + expect(JSON.stringify(plan)).not.toContain("CLERK_PUBLISHABLE_KEY="); + }); + + test("does not block native registration for case-only Bundle ID variants", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + target.configurations[0]!.bundleIdentifier = { + state: "resolved", + value: "com.Example.MyApp", + evidence: [], + }; + target.configurations[1]!.bundleIdentifier = { + state: "resolved", + value: "COM.EXAMPLE.MYAPP", + evidence: [], + }; + + const registration = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "register-native-application", + ); + + expect(registration).toMatchObject({ status: "review" }); + expect(registration?.description).toContain("com.Example.MyApp"); + expect(registration?.description).not.toContain("COM.EXAMPLE.MYAPP"); + }); + + test("continues to block genuinely different Bundle IDs", async () => { + const plan = await planFor({ complete: true, conflictingBundle: true }); + + expect(plan.steps.find((step) => step.id === "register-native-application")).toMatchObject({ + status: "blocked", + }); + }); + + test("classifies a LocalSecrets loader as a preserved custom key source", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.configureCalls).toEqual([ + { + inlinePublishableKey: undefined, + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "custom", + startupBinding: "app-init", + }, + ]); + expect(inspection.localPublishableKey.state).toBe("unproven"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("reviews configuration when an additional configure call is not proven", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.configureCalls.push({ + path: "MyApp/SecondarySetup.swift", + publishableKeyWiring: "custom", + startupBinding: "unproven", + }); + + const plan = buildIOSSetupPlan(inspection); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + + expect(configureStep).toMatchObject({ status: "review", automatable: false }); + expect(configureStep?.description).toContain("More than one Clerk.configure"); + const output = formatIOSSetupPlan(inspection, plan); + expect(output).toContain("Publishable key: configuration needs review (value not inspected)"); + expect(output).not.toContain("found but invalid"); + expect(output).not.toContain("Publishable key: not found"); + }); + + test("satisfies configuration and derives the domain from a redacted inline literal", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false }); + const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${publishableKey}") } + var body: some Scene { + WindowGroup { Text("Hello").environment(Clerk.shared) } + } +} +`, + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan.changes?.configuration).toBe("verify-existing"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.description).toContain( + "webcredentials:inline.clerk.example", + ); + expect(JSON.stringify(plan)).not.toContain(publishableKey); + }); + + test("marks safe fresh direct configuration and environment injection as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ + status: "ready", + changes: { + clerkKitImport: "insert", + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "required", + automatable: true, + }); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).toContain("directly"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(JSON.stringify(plan)).not.toContain("pk_test_"); + }); + + test("does not satisfy root environment setup from an unused same-file helper", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-root-environment-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } + } + struct UnusedHelper: View { + var body: some View { Text("Unused").environment(Clerk.shared) } + }`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.environmentInjections).toEqual([ + { path: "MyApp/MyAppApp.swift" }, + ]); + expect(inspection.appTargets[0]?.swift.rootEnvironmentInjections).toEqual([]); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "review", + automatable: false, + }); + expect( + plan.steps.find((step) => step.id === "inject-clerk-environment")?.description, + ).toContain("not proven on the shipping WindowGroup root"); + }); + + test("does not satisfy environment setup from an invalid EnvironmentValues overload", async () => { + for (const keyPath of ["\\.self", ".self"]) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-root-environment-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import ClerkKitUI + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { AuthView().environment(${keyPath}, Clerk.shared) } + } + }`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.environmentInjections).toEqual([]); + expect(inspection.appTargets[0]?.swift.rootEnvironmentInjections).toEqual([]); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "required", + automatable: false, + }); + } + }); + + test("advertises a proven prebuilt AuthView scaffold without selecting it", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "--prebuilt-auth-ui", + ); + }); + + test("uses the documented AuthView sheet when prebuilt authentication is selected", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-selected-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "network-free local plan", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "only if Apple is enabled", + ); + }); + + test("treats a custom email-link implementation as an existing authentication flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-magic-link-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } + } + func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + }); + + test("blocks a selected AuthView scaffold when the SDK compatibility proof fails", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-old-prebuilt-sdk-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + const message = "ClerkKitUI's documented native components require clerk-ios 1.0.0 or newer."; + + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan: { + status: "blocked", + blockers: [{ code: "incompatible-sdk", message }], + }, + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + message, + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("blocks an explicitly requested scaffold over a partial existing auth flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-partial-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.authFlowReferences = [{ path: "MyApp/ContentView.swift" }]; + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "blocked", + sourcePath: "MyApp/ContentView.swift", + actions: [], + blockers: [ + { + code: "existing-auth-integration", + message: "An existing or partial authentication flow must be reviewed manually.", + }, + ], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "partial authentication flow", + ); + }); + + test("maps every native Apple entitlement plan state into the ordered setup plan", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-native-apple-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + for (const fixture of [ + { + status: "ready" as const, + actions: ["Add the Apple entitlement."], + blockers: [], + expectedStatus: "required", + automatable: true, + text: "exact Default value", + }, + { + status: "satisfied" as const, + actions: [], + blockers: [], + expectedStatus: "satisfied", + automatable: false, + text: "exact native Sign in with Apple entitlement", + }, + { + status: "blocked" as const, + actions: [], + blockers: [{ code: "unsupported-entitlements" as const, message: "Review this file." }], + expectedStatus: "blocked", + automatable: false, + text: "Review this file.", + }, + ]) { + const plan = buildIOSSetupPlan(inspection, { appleEntitlementPlan: fixture }); + const stepIndex = plan.steps.findIndex((step) => step.id === "enable-native-apple"); + const domainIndex = plan.steps.findIndex((step) => step.id === "add-associated-domain"); + const appleStep = plan.steps[stepIndex]; + + expect(stepIndex).toBeGreaterThan(-1); + expect(stepIndex).toBeLessThan(domainIndex); + expect(appleStep).toMatchObject({ + status: fixture.expectedStatus, + automatable: fixture.automatable, + }); + expect(appleStep?.description).toContain(fixture.text); + } + }); + + test("surfaces strict Associated Domains blockers instead of asking for a local key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + releaseEntitlements: false, + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + const associatedDomainPlan = await planIOSAssociatedDomain({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + deferToPublishableKey: true, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan, associatedDomainPlan }); + const domain = plan.steps.find((step) => step.id === "add-associated-domain"); + + expect(associatedDomainPlan.status).toBe("blocked"); + expect(domain).toMatchObject({ status: "review", automatable: false }); + expect(domain?.description).toContain( + "Some selected-target configurations have entitlements while others do not", + ); + expect(domain?.description).not.toContain("valid local publishable key is needed"); + }); + + test("renders strict direct-config blockers as actionable blocked steps", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + generated: "xcodegen", + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ status: "blocked" }); + expect(directConfigPlan.blockers.map((blocker) => blocker.code)).toContain("generated-project"); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep).toMatchObject({ status: "blocked", automatable: false }); + expect(configureStep?.description).toContain("XcodeGen"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("does not satisfy a custom configure call outside app startup", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const source = await Bun.file(appPath).text(); + await Bun.write( + appPath, + source.replace( + 'init() { Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") }', + `init() {} + func unusedConfigureHelper() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") + }`, + ), + ); + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ + publishableKeyWiring: "custom", + startupBinding: "unproven", + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("classifies ProcessInfo wiring as a preserved custom key source", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "custom", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + expect(inspection.localPublishableKey.state).toBe("unproven"); + }); + + test("preserves an arbitrary named key loader without interpreting it", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + enum LocalSecrets { static let key = "" } + @main struct MyApp: App { + init() { Clerk.configure(publishableKey: LocalSecrets.key) } + var body: some Scene { WindowGroup { Text("Hello") } } + }`, + ); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + }); + + test("reports genuinely missing Swift setup as required", async () => { + const plan = await planFor({ complete: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe( + "required", + ); + }); + + test("plans ClerkKitUI by default for an untouched target", async () => { + const plan = await planFor({ clerkSDK: false, complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("ClerkKit and ClerkKitUI"); + expect(sdkStep?.description).toContain("prebuilt AuthView"); + }); + + test("plans ClerkKitUI for a source-blank core-only graph from an earlier setup", async () => { + const plan = await planFor({ clerkSDK: "core-only", complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("already has ClerkKit"); + expect(sdkStep?.description).toContain("Link ClerkKitUI"); + }); + + test("plans only ClerkKit when existing source shows custom-flow intent", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, complete: false, includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("custom-flow intent"); + expect(sdkStep?.description).toContain("ClerkKitUI is not required"); + const authStep = plan.steps.find((step) => step.id === "add-authentication-flow"); + expect(authStep?.description).toContain("custom ClerkKit"); + expect(authStep?.description).not.toContain("ClerkKitUI"); + }); + + test("requires ClerkKitUI when selected-target source imports its prebuilt UI", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + const output = formatIOSSetupPlan(inspection, plan); + expect(output).toContain("`clerk init` can apply this step."); + expect(output.match(/`clerk init` can apply this step\./g)).toHaveLength(1); + }); + + test("repairs a declared but unlinked ClerkKitUI product without source imports", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "declared"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("declared"); + expect(sdkStep?.description).toContain("not linked"); + }); + + test("does not mark generated-project SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, generated: "xcodegen" }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("does not mark unattributed SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("reviews linked Clerk products when their package reference is unattributed", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + + const plan = buildIOSSetupPlan(inspection); + const step = plan.steps.find((candidate) => candidate.id === "install-clerk-sdk"); + + expect(step?.status).toBe("review"); + expect(step?.description).toContain("could not be verified as clerk-ios"); + }); + + test("treats missing Swift evidence as review when source membership is incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, clerkSDK: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.evidenceComplete = false; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "review", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + "cannot safely choose", + ); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe("review"); + }); + + test("preserves an existing custom configure call without validating its value", async () => { + const plan = await planFor({ complete: true, includeKey: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("requires the bare domain when only Apple's developer-mode suffix is present", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const key = `pk_test_${Buffer.from("native.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI +@main struct MyApp: App { + init() { Clerk.configure(publishableKey: "${key}") } + var body: some Scene { WindowGroup { Text("Hello").environment(Clerk.shared) } } +}`, + ); + const inspection = await inspectIOSProject(root); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.status).toBe("required"); + }); + + test("matches only the associated-domain hostname case-insensitively", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const key = `pk_test_${Buffer.from("native.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI +@main struct MyApp: App { + init() { Clerk.configure(publishableKey: "${key}") } + var body: some Scene { WindowGroup { Text("Hello").environment(Clerk.shared) } } +}`, + ); + const inspection = await inspectIOSProject(root); + + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["webcredentials:NATIVE.CLERK.EXAMPLE"]; + } + expect( + buildIOSSetupPlan(inspection).steps.find((step) => step.id === "add-associated-domain"), + ).toMatchObject({ status: "satisfied" }); + + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["WEBCREDENTIALS:native.clerk.example"]; + } + expect( + buildIOSSetupPlan(inspection).steps.find((step) => step.id === "add-associated-domain"), + ).toMatchObject({ status: "required" }); + }); + + test("blocks all dependent steps when target selection is ambiguous", async () => { + const plan = await planFor({ secondTarget: true }); + + expect(plan.steps[0]?.status).toBe("blocked"); + expect(plan.steps.slice(1).every((step) => step.status === "blocked")).toBe(true); + }); + + test("includes usable choices when the requested target is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { secondTarget: true }); + const inspection = await inspectIOSProject(root, { target: "MissingApp" }); + + const plan = buildIOSSetupPlan(inspection); + + const selectStep = plan.steps.find((step) => step.id === "select-target"); + expect(selectStep?.status).toBe("blocked"); + expect(selectStep?.description).toContain("AdminApp"); + expect(selectStep?.description).toContain("MyApp"); + }); + + test("is deterministic for identical inspection input", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + expect(buildIOSSetupPlan(inspection)).toEqual(buildIOSSetupPlan(inspection)); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts new file mode 100644 index 000000000..88db89e62 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -0,0 +1,522 @@ +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupPlan, + IOSSetupStep, + IOSSetupStepStatus, + IOSSourceEvidence, + IOSValueResolution, +} from "./types.ts"; +import { clerkKitUIInstallDecision } from "./products.ts"; +import type { IOSDirectConfigPlan } from "./direct-config.ts"; +import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; +import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; +import type { IOSSDKInstallPlan } from "./install-sdk.ts"; +import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; + +const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; +const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; +const NATIVE_APPLE_URL = + "https://clerk.com/docs/ios/guides/configure/auth-strategies/sign-in-with-apple"; + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +function selectedEvidence(target: IOSAppTarget | undefined): IOSSourceEvidence[] { + return target ? [{ path: target.projectPath, objectId: target.id }] : []; +} + +function distinctResolvedBundleIdentifiers(target: IOSAppTarget): string[] { + const candidatesByIdentity = new Map(); + for (const configuration of target.configurations) { + const value = configuration.bundleIdentifier; + if (value.state !== "resolved") continue; + const identity = normalizeBundleIdentifierIdentity(value.value); + if (!candidatesByIdentity.has(identity)) candidatesByIdentity.set(identity, value.value); + } + return [...candidatesByIdentity.values()].sort(); +} + +function allEvidence( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): IOSSourceEvidence[] { + return target.configurations.flatMap((configuration) => select(configuration).evidence); +} + +function step( + id: IOSSetupStep["id"], + title: string, + status: IOSSetupStepStatus, + description: string, + evidence: IOSSourceEvidence[] = [], + links?: IOSSetupStep["links"], + automatable = false, +): IOSSetupStep { + return { id, title, status, automatable, description, links, evidence }; +} + +export interface BuildIOSSetupPlanOptions { + /** Strict SDK/package compatibility from the same planner used by apply. */ + sdkInstallPlan?: Pick; + /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ + directConfigPlan?: IOSDirectConfigPlan; + /** Strict existing-entitlements readiness from the same planner used by apply. */ + associatedDomainPlan?: Pick< + IOSAssociatedDomainPlan, + | "status" + | "expectedDomain" + | "requiresPublishableKey" + | "blockers" + | "files" + | "missingEntitlementsSettings" + >; + /** Optional native Apple capability requested or already present locally. */ + appleEntitlementPlan?: Pick; + /** Strict source readiness for the optional prebuilt AuthView scaffold. */ + prebuiltAuthPlan?: Pick; + /** Whether this invocation explicitly selected the optional AuthView scaffold. */ + prebuiltAuthSelected?: boolean; +} + +export function buildIOSSetupPlan( + inspection: IOSProjectInspectionResult, + options: BuildIOSSetupPlanOptions = {}, +): IOSSetupPlan { + const target = selectedTarget(inspection); + const targetEvidence = selectedEvidence(target); + const steps: IOSSetupStep[] = []; + + steps.push( + step( + "select-target", + "Select the iOS application target", + target ? "satisfied" : "blocked", + target + ? `Using ${target.name} in ${target.projectPath}.` + : inspection.selection.state === "ambiguous" + ? "More than one iOS app target is eligible. Rerun with --target ; the CLI will not guess." + : inspection.selection.state === "not-found" + ? `The requested target "${inspection.selection.requested}" was not found.${inspection.selection.candidates.length > 0 ? ` Available targets: ${inspection.selection.candidates.join(", ")}.` : ""}` + : "No usable iOS application target was found.", + targetEvidence, + ), + ); + + if (!target) { + const blockedSteps: Array<[IOSSetupStep["id"], string]> = [ + ["install-clerk-sdk", "Install Clerk's iOS SDK"], + ["configure-publishable-key", "Configure Clerk"], + ["inject-clerk-environment", "Inject Clerk into SwiftUI"], + ["register-native-application", "Register the native application"], + ["add-associated-domain", "Add the associated domain"], + ["add-authentication-flow", "Add an authentication flow"], + ["verify-integration", "Verify the integration"], + ]; + for (const [id, title] of blockedSteps) { + steps.push( + step(id, title, "blocked", "Select an iOS application target before planning this step."), + ); + } + return finishPlan(inspection, steps); + } + + const usesClerkKitUI = target.swift.importsClerkKitUI.length > 0; + const productDecision = clerkKitUIInstallDecision(target); + const includeClerkKitUI = + productDecision === "prebuilt" || + options.prebuiltAuthSelected === true || + options.prebuiltAuthPlan?.status === "satisfied"; + const sourceEntryPointIsAmbiguous = target.swift.status === "ambiguous"; + const requiredProductsLinked = + target.packages.clerkKit === "linked" && + (!includeClerkKitUI || target.packages.clerkKitUI === "linked"); + const packageIsVerified = + target.packages.package === "remote" || target.packages.package === "local"; + const strictSDKBlocked = options.sdkInstallPlan?.status === "blocked"; + const strictSDKBlocker = strictSDKBlocked + ? options.sdkInstallPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const sdkStatus: IOSSetupStepStatus = strictSDKBlocked + ? "blocked" + : productDecision === "unknown" + ? "review" + : !requiredProductsLinked + ? "required" + : packageIsVerified + ? "satisfied" + : target.packages.package === "unattributed" + ? "review" + : "required"; + const sdkAutomatable = + sdkStatus === "required" && + inspection.generatedProject === null && + target.packages.package !== "unattributed"; + steps.push( + step( + "install-clerk-sdk", + "Install Clerk's iOS SDK for the selected target", + sdkStatus, + strictSDKBlocked + ? `The selected Clerk iOS SDK cannot support this approved setup safely: ${strictSDKBlocker ?? "Update the clerk-ios package and rerun the plan."}` + : productDecision === "unknown" + ? `Swift source membership for ${target.name} is incomplete, so the CLI cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the source-membership diagnostics or make the product choice manually.` + : sdkStatus === "satisfied" + ? `ClerkKit is linked to ${target.name}${target.packages.clerkKitUI === "linked" ? "; ClerkKitUI is linked too" : ""}.` + : sdkStatus === "review" + ? `ClerkKit${target.packages.clerkKitUI === "linked" ? " and ClerkKitUI are" : " is"} linked to ${target.name}, but the package reference could not be verified as clerk-ios. Confirm the linked products come from Clerk's remote or local package.` + : includeClerkKitUI && target.packages.clerkKitUI !== "linked" + ? usesClerkKitUI + ? `${target.name} imports ClerkKitUI, but that product is not linked to the target. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package.` + : target.packages.clerkKitUI === "declared" + ? `ClerkKitUI is declared for ${target.name} but not linked in its Frameworks phase. Link it alongside ClerkKit.` + : target.packages.clerkKit !== "absent" + ? `${target.name} already has ClerkKit but no source-proven custom flow. Link ClerkKitUI from the same clerk-ios package so the prebuilt AuthView path is ready by default.` + : `${target.name} has no existing Clerk integration. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package so the prebuilt AuthView is ready by default.` + : includeClerkKitUI + ? `Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit and ClerkKitUI to ${target.name} for the fastest prebuilt AuthView path.` + : `${target.name} already shows core-only or custom-flow intent. Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit; ClerkKitUI is not required for that path.`, + targetEvidence, + undefined, + sdkAutomatable, + ), + ); + + const configured = target.swift.configureCalls.length > 0; + const oneStartupConfigure = + target.swift.evidenceComplete && + !sourceEntryPointIsAmbiguous && + target.swift.configureCalls.length === 1 && + target.swift.configureCalls[0]?.startupBinding === "app-init"; + const configureCall = target.swift.configureCalls[0]; + const inlineConfigureValid = + oneStartupConfigure && + configureCall?.publishableKeyWiring === "inline-literal" && + configureCall.inlinePublishableKey?.state === "valid"; + const customConfigureReady = + oneStartupConfigure && configureCall?.publishableKeyWiring === "custom"; + const publishableKeyBlocked = + oneStartupConfigure && + configureCall?.publishableKeyWiring === "inline-literal" && + configureCall.inlinePublishableKey?.state === "invalid"; + const directConfigPlanApplies = options.directConfigPlan != null; + const directConfigAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.configuration !== "verify-existing"; + const directConfigBlocked = + directConfigPlanApplies && options.directConfigPlan?.status === "blocked"; + const directConfigBlocker = directConfigBlocked + ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const configuredStatus: IOSSetupStepStatus = publishableKeyBlocked + ? "blocked" + : directConfigBlocked + ? "blocked" + : configured + ? inlineConfigureValid || customConfigureReady + ? "satisfied" + : "review" + : directConfigAutomationReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "configure-publishable-key", + "Configure Clerk with a publishable key", + configuredStatus, + publishableKeyBlocked + ? "The inline Clerk publishable key is malformed. Replace it before relying on Clerk.configure(...)." + : directConfigBlocked + ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` + : configured + ? target.swift.configureCalls.length > 1 + ? "More than one Clerk.configure(...) call is present. Confirm which call configures the shipping app before continuing." + : sourceEntryPointIsAmbiguous + ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." + : inlineConfigureValid + ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." + : customConfigureReady + ? "Clerk is configured at app startup through a custom publishable-key source. clerk init will preserve that source and require the developer to select its Clerk application; the value is not inspected or independently verified." + : "A Clerk.configure(...) call is present, but it is not proven to run from the selected app's startup initializer. Confirm the shipping configuration manually; the expression is intentionally redacted." + : !target.swift.evidenceComplete + ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." + : directConfigAutomationReady + ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` + : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", + target.swift.configureCalls, + undefined, + directConfigAutomationReady, + ), + ); + + const provenAppRoot = + !sourceEntryPointIsAmbiguous && + target.swift.entryPoints.length === 1 && + target.swift.appRootEvidence.length === 1 && + target.swift.appRootEvidence[0]?.path === target.swift.entryPoints[0]?.path; + const injected = + provenAppRoot && + target.swift.rootEnvironmentInjections.some( + (evidence) => evidence.path === target.swift.appRootEvidence[0]?.path, + ); + const hasUnprovenInjection = target.swift.environmentInjections.length > 0 && !injected; + const requiresSwiftUIEnvironment = + target.swift.environmentConsumers.length > 0 || includeClerkKitUI || directConfigPlanApplies; + const directEnvironmentAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.environment === "insert"; + const directEnvironmentBlocked = !injected && requiresSwiftUIEnvironment && directConfigBlocked; + const injectedStatus: IOSSetupStepStatus = injected + ? "satisfied" + : directEnvironmentBlocked + ? "blocked" + : requiresSwiftUIEnvironment + ? target.swift.evidenceComplete && provenAppRoot && !hasUnprovenInjection + ? "required" + : "review" + : "review"; + steps.push( + step( + "inject-clerk-environment", + "Inject Clerk into the SwiftUI environment", + injectedStatus, + injected + ? "Clerk.shared is injected into the proven shipping WindowGroup root." + : directEnvironmentBlocked + ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's WindowGroup root manually."}` + : hasUnprovenInjection + ? "A Clerk.shared environment modifier exists in target source, but it is not proven on the shipping WindowGroup root. Confirm the mounted root manually." + : requiresSwiftUIEnvironment && !provenAppRoot + ? "The shipping SwiftUI root could not be proven structurally. Confirm that its mounted root injects Clerk.shared." + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? directEnvironmentAutomationReady + ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` + : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." + : requiresSwiftUIEnvironment + ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." + : "No target source was found consuming Clerk from SwiftUI's environment. Add `.environment(Clerk.shared)` only if AuthView or an `@Environment(Clerk.self)` view needs it.", + injected ? target.swift.rootEnvironmentInjections : target.swift.environmentInjections, + undefined, + directEnvironmentAutomationReady, + ), + ); + + const bundleIdentifiers = distinctResolvedBundleIdentifiers(target); + const appPrefixes = [ + ...new Set( + target.configurations + .map((configuration) => configuration.entitlements?.literalAppIdentifierPrefix) + .filter((value): value is string => value != null), + ), + ].sort(); + const registrationBlocked = + target.configurations.length === 0 || + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state !== "resolved", + ) || + bundleIdentifiers.length !== 1; + steps.push( + step( + "register-native-application", + "Register the iOS app in Clerk Dashboard", + registrationBlocked ? "blocked" : "review", + registrationBlocked + ? "A single Bundle ID could not be resolved across build configurations. Make it explicit or consistent before registering the app." + : appPrefixes.length === 1 + ? `The source entitlements contain the literal App ID Prefix candidate ${appPrefixes[0]} for ${bundleIdentifiers[0]}. Confirm it in Apple Developer, then verify the app is registered and Native API is enabled. Dashboard state is not changed or assumed by dry-run.` + : `Verify that ${bundleIdentifiers[0]} is registered and Native API is enabled. Supply the Apple App ID Prefix from the Developer portal; DEVELOPMENT_TEAM is not assumed to be the prefix.`, + allEvidence(target, (configuration) => configuration.bundleIdentifier), + [{ kind: "dashboard", url: NATIVE_APPLICATIONS_URL }], + ), + ); + + if (options.appleEntitlementPlan) { + const appleStatus: IOSSetupStepStatus = + options.appleEntitlementPlan.status === "ready" + ? "required" + : options.appleEntitlementPlan.status === "satisfied" + ? "satisfied" + : "blocked"; + const description = + options.appleEntitlementPlan.status === "ready" + ? "Add the native Sign in with Apple entitlement with the exact Default value. After authentication, clerk init will separately audit and enable the matching Clerk Apple connection without requesting hosted/web Apple credentials." + : options.appleEntitlementPlan.status === "satisfied" + ? "The selected target has the exact native Sign in with Apple entitlement. Regular clerk init will verify the matching Clerk Apple connection after authentication." + : `Native Sign in with Apple needs review: ${options.appleEntitlementPlan.blockers.map((item) => item.message).join(" ")}`; + steps.push( + step( + "enable-native-apple", + "Enable native Sign in with Apple", + appleStatus, + description, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + [{ kind: "documentation", url: NATIVE_APPLE_URL }], + options.appleEntitlementPlan.status === "ready", + ), + ); + } + + const expectedDomain = + inspection.localPublishableKey.state === "valid" + ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` + : undefined; + const expectedDomainIsSelectedTargetRuntime = inlineConfigureValid; + const entitlements = target.configurations + .map((configuration) => configuration.entitlements) + .filter((value) => value != null); + const allEntitlementsPresent = + entitlements.length === target.configurations.length && entitlements.length > 0; + const domainPresent = + expectedDomain != null && + allEntitlementsPresent && + entitlements.every((value) => + value.associatedDomains.some((domain) => associatedDomainMatches(domain, expectedDomain)), + ); + const hasUnresolvedAssociatedDomains = entitlements.some( + (value) => value.unresolvedAssociatedDomains.length > 0, + ); + const associatedDomainPlan = options.associatedDomainPlan; + const associatedDomainStatus: IOSSetupStepStatus = + associatedDomainPlan?.status === "ready" + ? "required" + : associatedDomainPlan?.status === "satisfied" + ? "satisfied" + : associatedDomainPlan?.status === "blocked" + ? "review" + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? "review" + : domainPresent + ? "satisfied" + : expectedDomain && allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? "review" + : expectedDomain + ? "required" + : "blocked"; + const associatedDomainDescription = + associatedDomainPlan?.status === "ready" + ? associatedDomainPlan.expectedDomain + ? associatedDomainPlan.missingEntitlementsSettings + ? `Create and attach ${associatedDomainPlan.files[0]?.path ?? "an entitlements file"} only to iPhone and iPad builds, then add ${associatedDomainPlan.expectedDomain}. clerk init can apply this safely.` + : `Add ${associatedDomainPlan.expectedDomain} to every selected-target entitlements configuration. clerk init can apply the exact existing-file edits safely.` + : associatedDomainPlan.missingEntitlementsSettings + ? `The selected target has one safe synchronized destination for a new entitlements file. clerk init will create and attach it only to iPhone and iPad builds, then add the linked development application's exact webcredentials host without exposing the publishable key.` + : "The existing selected-target entitlements files are safe to edit. clerk init will derive the exact webcredentials host from the linked development application after authentication and add it without exposing the publishable key." + : associatedDomainPlan?.status === "blocked" + ? `Automatic Associated Domains setup needs review: ${associatedDomainPlan.blockers.map((blocker) => blocker.message).join(" ")}` + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? domainPresent + ? `${expectedDomain} matches every inspected entitlements configuration, but the key is only available to copy and is not proven to be the selected target's runtime key. Confirm the runtime key before treating this domain as final.` + : `The available key candidate maps to ${expectedDomain}, but it is not proven to be the selected target's runtime key. Wire or confirm the runtime key before adding its Associated Domain.` + : domainPresent + ? `${expectedDomain} is present in every inspected entitlements configuration.` + : expectedDomain + ? allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? `Some associated-domain values use unresolved build settings. Confirm they expand to ${expectedDomain} in every selected-target configuration.` + : `Enable Associated Domains for ${target.name} and add ${expectedDomain} to every selected-target entitlements configuration.` + : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; + steps.push( + step( + "add-associated-domain", + "Add Clerk's associated domain", + associatedDomainStatus, + associatedDomainDescription, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + undefined, + associatedDomainPlan?.status === "ready", + ), + ); + + const hasAuthFlow = target.swift.authFlowReferences.length > 0; + const prebuiltAuthReady = options.prebuiltAuthPlan?.status === "ready" && !strictSDKBlocked; + const prebuiltAuthSatisfied = options.prebuiltAuthPlan?.status === "satisfied"; + const selectedPrebuiltAuthBlocked = + options.prebuiltAuthSelected === true && + (options.prebuiltAuthPlan?.status === "blocked" || strictSDKBlocked); + const authFlowStatus: IOSSetupStepStatus = selectedPrebuiltAuthBlocked + ? "blocked" + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "review" + : "satisfied" + : prebuiltAuthReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "add-authentication-flow", + "Add an authentication flow", + authFlowStatus, + selectedPrebuiltAuthBlocked + ? `The prebuilt AuthView scaffold was requested, but this app is not safe to rewrite automatically: ${strictSDKBlocker ?? options.prebuiltAuthPlan?.blockers.map((blocker) => blocker.message).join(" ") ?? "Review the existing signed-out route and integrate AuthView manually."} Linked AuthView providers are not inspected by this network-free local plan.` + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "A Clerk authentication flow is referenced, but multiple @main entry points make the shipping route ambiguous." + : prebuiltAuthSatisfied + ? "ClerkKitUI's documented UserButton entry and AuthView sheet are already configured in target source." + : "A Clerk authentication UI or sign-in/sign-up flow is referenced in target source." + : prebuiltAuthReady + ? options.prebuiltAuthSelected + ? `Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to ${options.prebuiltAuthPlan?.sourcePath ?? "the proven placeholder SwiftUI view"}. Linked AuthView providers are not inspected by this network-free local plan; regular clerk init will add or verify the local Sign in with Apple entitlement only if Apple is enabled for the linked instance.` + : `This target's pristine placeholder is eligible for the optional prebuilt AuthView scaffold. Run clerk init with --prebuilt-auth-ui or select it when prompted; existing application UI is never replaced automatically.` + : target.swift.evidenceComplete + ? productDecision === "core-only" + ? "Complete the custom ClerkKit sign-in/sign-up flow and route signed-out users to it." + : "Present ClerkKitUI's AuthView or build a custom ClerkKit sign-in/sign-up flow, then route signed-out users to it." + : "No Clerk authentication flow was found in the safely inspected source subset. Confirm the signed-out route manually.", + target.swift.authFlowReferences, + undefined, + prebuiltAuthReady, + ), + ); + + const actionable = steps.some((item) => item.status === "required" || item.status === "blocked"); + steps.push( + step( + "verify-integration", + "Build and verify sign-in", + "review", + actionable + ? "After completing the required steps, build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled." + : "The local evidence looks complete. Build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled.", + targetEvidence, + [{ kind: "documentation", url: QUICKSTART_URL }], + ), + ); + + return finishPlan(inspection, steps); +} + +function finishPlan(inspection: IOSProjectInspectionResult, steps: IOSSetupStep[]): IOSSetupPlan { + const summary: IOSSetupPlan["summary"] = { + satisfied: 0, + required: 0, + review: 0, + blocked: 0, + }; + for (const item of steps) summary[item.status]++; + const status: IOSSetupPlan["status"] = + summary.blocked > 0 ? "blocked" : summary.required > 0 ? "action-required" : "ready"; + + return { + schemaVersion: 1, + kind: "clerk-ios-setup", + root: inspection.root, + status, + selection: inspection.selection, + summary, + steps, + diagnostics: inspection.diagnostics, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts new file mode 100644 index 000000000..4abff2c42 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -0,0 +1,414 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { afterEach, describe, expect, test } from "bun:test"; +import type { PbxObjects } from "./pbx.ts"; +import { + applyIOSPrebuiltAuth, + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const CONTENT_FILE_ID = "616161616161616161616161"; +const CONTENT_BUILD_FILE_ID = "626262626262626262626262"; +const SHARED_CONTENT_BUILD_FILE_ID = "636363636363636363636363"; + +const APP_SOURCE = `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`; + +const CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +async function createFixture(options: { shared?: boolean; crlf?: boolean } = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: true, + includeKey: false, + secondTarget: options.shared === true, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + if (options.shared) { + (objects[IOS_FIXTURE_IDS.secondSourcesPhase]!.files as string[]).push( + SHARED_CONTENT_BUILD_FILE_ID, + ); + objects[SHARED_CONTENT_BUILD_FILE_ID] = { + isa: "PBXBuildFile", + fileRef: CONTENT_FILE_ID, + }; + } + await writeFile(projectPath, buildPbxProject(project)); + await writeFile(join(root, "MyApp", "MyAppApp.swift"), APP_SOURCE); + const content = options.crlf ? CONTENT_SOURCE.replace(/\n/g, "\r\n") : CONTENT_SOURCE; + await writeFile(join(root, "MyApp", "ContentView.swift"), content); + return root; +} + +function options(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + allowDirty: true, + } as const; +} + +async function updateDeploymentTargets( + root: string, + update: (settings: Record, configurationId: string) => void, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[configurationId]?.buildSettings; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error(`Missing fixture build settings for ${configurationId}.`); + } + update(settings as Record, configurationId); + } + await writeFile(projectPath, buildPbxProject(project)); +} + +describe("prebuilt AuthView source setup", () => { + test("plans only an exact target-owned untouched SwiftUI placeholder", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + + expect(plan).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status: "ready", + appSourcePath: "MyApp/MyAppApp.swift", + sourcePath: "MyApp/ContentView.swift", + blockers: [], + }); + expect(JSON.stringify(plan)).not.toContain("AuthView()"); + expect(JSON.stringify(plan)).not.toContain("Hello, world!"); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared AuthView mutation"); + expect(prepared.mutation.boundary.rootPath).toBe(root); + expect(prepared.mutation.boundary.realParentPath.endsWith("/MyApp")).toBe(true); + expect(JSON.stringify(prepared)).not.toContain("boundary"); + }); + + test.each([ + { + name: "one selected configuration below iOS 17", + update(settings: Record, configurationId: string) { + settings.IPHONEOS_DEPLOYMENT_TARGET = + configurationId === IOS_FIXTURE_IDS.targetDebug ? "17.0" : "16.4"; + }, + }, + { + name: "an unresolved deployment target", + update(settings: Record) { + settings.IPHONEOS_DEPLOYMENT_TARGET = "$(PRIVATE_IOS_MINIMUM)"; + }, + }, + { + name: "conflicting device and simulator deployment targets", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]"] = "17.0"; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]"] = "16.0"; + }, + }, + { + name: "a missing deployment target", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + }, + }, + ])("blocks $name with fixed guidance and no source write", async ({ update }) => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const sourceBefore = await readFile(sourcePath); + await updateDeploymentTargets(root, update); + + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toEqual([ + { + code: "incompatible-deployment-target", + message: + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + }, + ]); + expect(JSON.stringify(plan)).not.toContain("PRIVATE_IOS_MINIMUM"); + expect(result.status).toBe("blocked"); + expect(await readFile(sourcePath)).toEqual(sourceBefore); + }); + + test("writes the documented AuthView presentation and is byte-idempotent", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await chmod(sourcePath, 0o640); + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + const source = await readFile(sourcePath, "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toBe(GENERATED_CONTENT_SOURCE); + expect(source).not.toContain("@Environment"); + expect(source).not.toContain(".onOpenURL"); + expect(source).not.toContain("clerk.auth.events"); + expect(source).not.toContain("clerk.session?.tasks"); + expect(source).not.toContain(".alert("); + expect(source).not.toContain("#Preview"); + expect((await Bun.file(sourcePath).stat()).mode & 0o777).toBe(0o640); + + const rerun = await planIOSPrebuiltAuth(options(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSPrebuiltAuth(rerun)).status).toBe("satisfied"); + expect(await readFile(sourcePath, "utf8")).toBe(source); + }); + + test("preserves CRLF and the existing Xcode header", async () => { + const root = await createFixture({ crlf: true }); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const plan = await planIOSPrebuiltAuth(options(root)); + expect((await applyIOSPrebuiltAuth(plan)).status).toBe("applied"); + const source = await readFile(sourcePath, "utf8"); + + expect(source.startsWith("//\r\n// ContentView.swift\r\n// MyApp\r\n//\r\n\r\n")).toBe(true); + expect(source.includes("\r\n")).toBe(true); + expect(/(^|[^\r])\n/.test(source)).toBe(false); + }); + + test("refuses customized UI instead of replacing it", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await writeFile( + sourcePath, + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Customer dashboard")'), + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("missing-placeholder"); + expect(await readFile(sourcePath, "utf8")).toContain("Customer dashboard"); + }); + + test("refuses source shared with another target", async () => { + const root = await createFixture({ shared: true }); + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("returns the replanned source blocker without exposing a concurrent edit", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile( + join(root, "MyApp", "ContentView.swift"), + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Concurrent edit")'), + ); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "missing-placeholder" }), + ); + expect(JSON.stringify(prepared)).not.toContain("Concurrent edit"); + }); + + test("returns the replanned blocker before comparing stale source identity", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile(join(root, "Project.swift"), "import ProjectDescription\n"); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "generated-project" }), + ); + }); + + test("accepts the direct-configured app root without touching it", async () => { + const root = await createFixture(); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const encodedHost = Buffer.from("example.clerk.accounts.dev$").toString("base64"); + await writeFile( + appPath, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "pk_test_${encodedHost}") + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("ready"); + expect(plan.appSourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test("requires the exact ContentView root to belong to the shipping SwiftUI App", async () => { + const root = await createFixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI + +@main +struct MyApp { + static func main() {} +} + +struct DecoyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-app-structure"); + }); + + test("refuses a source shared with a project below the normal discovery depth", async () => { + const root = await createFixture(); + const deepRoot = join(root, "a", "b", "c", "d"); + await mkdir(deepRoot, { recursive: true }); + await createIOSFixture(deepRoot, { + clerkSDK: false, + includeKey: false, + }); + + const projectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject( + (await readFile(projectPath, "utf8")).replaceAll( + IOS_FIXTURE_IDS.appTarget, + IOS_FIXTURE_IDS.secondTarget, + ), + ); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "../../../../../MyApp/ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + await writeFile(projectPath, buildPbxProject(project)); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("fails closed when exhaustive container discovery reaches its safety bound", async () => { + const root = await createFixture(); + const beyondBound = Array.from({ length: 26 }, (_, index) => `level-${index}`).reduce( + (directory, component) => join(directory, component), + root, + ); + await mkdir(beyondBound, { recursive: true }); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("incomplete-source-membership"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts new file mode 100644 index 000000000..618119354 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -0,0 +1,766 @@ +import { lstat } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +import { readIOSSourceSnapshot, newlineStyle, type IOSSourceSnapshot } from "./source-snapshot.ts"; +import { generatedProjectKind } from "./project-selection.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + type IOSExistingFileMutation, + type IOSFileMutationBoundary, +} from "./file-transaction.ts"; +import { hasExactIOSSwiftUIAppContentRoot } from "./direct-config.ts"; +import { + hasIncompleteIOSContainerDiscovery, + inspectIOSProject, + inspectIOSSourceMembership, +} from "./inspect.ts"; +import type { IOSBuildConfiguration } from "./types.ts"; + +export interface IOSPrebuiltAuthPlanOptions { + root: string; + projectPath: string; + targetId: string; + allowDirty?: boolean; +} + +export type IOSPrebuiltAuthBlockerCode = + | "invalid-selection" + | "target-not-found" + | "generated-project" + | "incompatible-deployment-target" + | "incomplete-source-membership" + | "ambiguous-entry-point" + | "unsupported-app-structure" + | "missing-placeholder" + | "shared-source" + | "unreadable-source" + | "unsupported-encoding" + | "unsupported-line-endings" + | "existing-auth-integration" + | "existing-authentication-flow" + | "runtime-prerequisites" + | "dirty-source" + | "git-state-unknown"; + +export interface IOSPrebuiltAuthBlocker { + code: IOSPrebuiltAuthBlockerCode; + message: string; +} + +/** A redacted, serializable semantic source plan. */ +export interface IOSPrebuiltAuthPlan { + schemaVersion: 1; + kind: "clerk-ios-prebuilt-auth"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + allowDirty: boolean; + appSourcePath?: string; + expectedAppSourceHash?: string; + sourcePath?: string; + expectedSourceHash?: string; + actions: string[]; + blockers: IOSPrebuiltAuthBlocker[]; +} + +/** @internal Candidate bytes are hidden from ordinary serialization. */ +export interface IOSPrebuiltAuthFileMutation { + absolutePath: string; + boundary: IOSFileMutationBoundary; + expectedHash: string; + candidateHash: string; + mode: number; + originalBytes: Uint8Array; + candidateBytes: Uint8Array; +} + +export type PreparedIOSPrebuiltAuthMutation = + | { + status: "ready"; + plan: IOSPrebuiltAuthPlan; + mutation: IOSPrebuiltAuthFileMutation; + } + | { + status: "satisfied" | "blocked" | "stale"; + plan: IOSPrebuiltAuthPlan; + message?: string; + mutation?: undefined; + }; + +export interface IOSPrebuiltAuthApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSPrebuiltAuthPlan; + message?: string; +} + +interface SourceSnapshot extends IOSSourceSnapshot { + newline: "\n" | "\r\n"; +} + +interface PreparedPlan { + plan: IOSPrebuiltAuthPlan; + appSnapshot?: SourceSnapshot; + sourceSnapshot?: SourceSnapshot; + sourceHeader?: string; +} + +const preparedValidators = new WeakMap Promise>(); + +function makePlan( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + status: IOSPrebuiltAuthPlan["status"], + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + | "appSourcePath" + | "expectedAppSourceHash" + | "sourcePath" + | "expectedSourceHash" + | "actions" + | "blockers" + > + > = {}, +): IOSPrebuiltAuthPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status, + root, + projectPath, + targetId: options.targetId, + allowDirty: options.allowDirty === true, + appSourcePath: details.appSourcePath, + expectedAppSourceHash: details.expectedAppSourceHash, + sourcePath: details.sourcePath, + expectedSourceHash: details.expectedSourceHash, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + code: IOSPrebuiltAuthBlockerCode, + message: string, + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + "appSourcePath" | "expectedAppSourceHash" | "sourcePath" | "expectedSourceHash" + > + > = {}, +): PreparedPlan { + return { + plan: makePlan(options, root, projectPath, "blocked", { + ...details, + blockers: [{ code, message }], + }), + }; +} + +async function sourceSnapshot( + root: string, + relativePath: string, +): Promise { + const snapshot = await readIOSSourceSnapshot(root, relativePath); + if (!snapshot) return undefined; + const newline = newlineStyle(snapshot.source); + return newline ? { ...snapshot, newline } : undefined; +} + +function splitHeader(source: string): { header: string; body: string } | undefined { + const importMatch = /^[\t ]*import[\t ]+(?:ClerkKit|ClerkKitUI|SwiftUI)[\t ]*$/m.exec(source); + if (importMatch?.index == null) return undefined; + const header = source.slice(0, importMatch.index); + const validHeader = header + .split(/\r?\n/) + .every((line) => line.trim() === "" || line.trimStart().startsWith("//")); + if (!validHeader || header.includes("/*")) return undefined; + return { header, body: source.slice(importMatch.index) }; +} + +function compactSwift(source: string): string | undefined { + let result = ""; + let inString = false; + let escaped = false; + for (let cursor = 0; cursor < source.length; cursor += 1) { + const character = source[cursor] ?? ""; + if (inString) { + result += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (!/\s/.test(character)) { + result += character; + } + } + return inString ? undefined : result; +} + +function supportsPrebuiltAuthDeploymentTarget(value: string): boolean { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim()); + if (!match) return false; + const components = match.slice(1).map((component) => Number(component ?? "0")); + if (components.some((component) => !Number.isSafeInteger(component))) return false; + return (components[0] ?? 0) >= 17; +} + +function targetSupportsPrebuiltAuth(configurations: IOSBuildConfiguration[]): boolean { + return ( + configurations.length > 0 && + configurations.every( + (configuration) => + configuration.deploymentTarget.state === "resolved" && + supportsPrebuiltAuthDeploymentTarget(configuration.deploymentTarget.value), + ) + ); +} + +const PRISTINE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const SIMPLE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, world!") + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_VIEW = `import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const pristineForms = new Set( + [PRISTINE_CONTENT_VIEW, SIMPLE_CONTENT_VIEW].map((source) => compactSwift(source)), +); +const generatedForm = compactSwift(GENERATED_CONTENT_VIEW); + +function classifyContentView(source: string): { + kind: "pristine" | "generated" | "other"; + header?: string; +} { + const split = splitHeader(source); + if (!split || split.body.includes("//") || split.body.includes("/*")) return { kind: "other" }; + const compact = compactSwift(split.body); + if (compact != null && compact === generatedForm) + return { kind: "generated", header: split.header }; + if (compact != null && pristineForms.has(compact)) + return { kind: "pristine", header: split.header }; + return { kind: "other" }; +} + +async function gitDirtyState( + root: string, + absolutePath: string, +): Promise<"clean" | "dirty" | "not-repository" | "unknown"> { + try { + const child = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", absolutePath], + { cwd: root, stdout: "pipe", stderr: "ignore" }, + ); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode === 0) return output.trim() === "" ? "clean" : "dirty"; + const probe = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { + cwd: root, + stdout: "ignore", + stderr: "ignore", + }); + return (await probe.exited) === 0 ? "unknown" : "not-repository"; + } catch { + return "unknown"; + } +} + +async function sourceIdentityOccurrences( + memberships: Awaited>, + snapshot: SourceSnapshot, +): Promise { + let occurrences = 0; + try { + for (const membership of memberships) { + if (!membership.complete) return undefined; + for (const file of membership.files) { + const info = await lstat(file.absolutePath); + if (!info.isFile() || info.isSymbolicLink()) return undefined; + if (info.dev === snapshot.device && info.ino === snapshot.inode) occurrences += 1; + } + } + return occurrences; + } catch { + return undefined; + } +} + +async function preparePlan(options: IOSPrebuiltAuthPlanOptions): Promise { + const root = resolve(options.root); + const absoluteProjectPath = resolve(root, options.projectPath); + if ( + !options.targetId || + !options.projectPath || + resolve(root, relative(root, absoluteProjectPath)) !== absoluteProjectPath || + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) + ) { + return blocked( + options, + root, + options.projectPath, + "invalid-selection", + "The selected Xcode project or target is invalid.", + ); + } + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const inspection = await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete local Xcode container discovery could not be proven.", + ); + } + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target could not be proven.", + ); + } + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (generator != null) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated Swift sources.`, + ); + } + const target = inspection.appTargets.find( + (candidate) => candidate.id === options.targetId && candidate.projectPath === projectPath, + ); + if (!target) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target disappeared during inspection.", + ); + } + if (!targetSupportsPrebuiltAuth(target.configurations)) { + return blocked( + options, + root, + projectPath, + "incompatible-deployment-target", + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + ); + } + if (!target.swift.evidenceComplete) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "The selected target's complete Swift source membership could not be proven.", + ); + } + if (target.swift.entryPoints.length !== 1 || !target.swift.entryPoints[0]?.path) { + return blocked( + options, + root, + projectPath, + "ambiguous-entry-point", + "The selected target must contain exactly one shipping @main Swift entry point.", + ); + } + const appSourcePath = target.swift.entryPoints[0].path; + const appSnapshot = await sourceSnapshot(root, appSourcePath); + if (!appSnapshot) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "The selected @main Swift source is not a safe, readable in-root regular file.", + { appSourcePath }, + ); + } + const appDetails = { + appSourcePath, + expectedAppSourceHash: appSnapshot.hash, + }; + if (!hasExactIOSSwiftUIAppContentRoot(appSnapshot.source)) { + return blocked( + options, + root, + projectPath, + "unsupported-app-structure", + "The shipping WindowGroup must have one direct ContentView root before the optional prebuilt UI can be added.", + appDetails, + ); + } + + const memberships = await inspectIOSSourceMembership(root); + const selectedMembership = memberships.find( + (membership) => + membership.targetId === options.targetId && membership.projectPath === projectPath, + ); + if (!selectedMembership?.complete || memberships.some((membership) => !membership.complete)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete source ownership across every local native target could not be proven.", + appDetails, + ); + } + const contentCandidates = selectedMembership.files.filter( + (file) => + basename(file.absolutePath) === "ContentView.swift" && + dirname(file.absolutePath) === dirname(appSnapshot.absolutePath), + ); + if (contentCandidates.length !== 1 || !contentCandidates[0]) { + return blocked( + options, + root, + projectPath, + "missing-placeholder", + "The selected target does not have one separate target-owned ContentView.swift beside its @main source.", + appDetails, + ); + } + const sourcePath = contentCandidates[0].relativePath; + const sourceSnapshotValue = await sourceSnapshot(root, sourcePath); + if (!sourceSnapshotValue) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "ContentView.swift is not a safe, readable in-root regular UTF-8 source file.", + { ...appDetails, sourcePath }, + ); + } + const sourceDetails = { + ...appDetails, + sourcePath, + expectedSourceHash: sourceSnapshotValue.hash, + }; + const identityOccurrences = await sourceIdentityOccurrences(memberships, sourceSnapshotValue); + if (identityOccurrences !== 1) { + return blocked( + options, + root, + projectPath, + "shared-source", + "ContentView.swift is shared, aliased, or not exclusively owned by the selected target.", + sourceDetails, + ); + } + + const classification = classifyContentView(sourceSnapshotValue.source); + if (classification.kind === "generated") { + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "satisfied", { + ...sourceDetails, + actions: [ + `Verify ClerkKitUI's prebuilt UserButton and AuthView presentation in ${sourcePath}.`, + "Verify Clerk images are prefetched for the prebuilt authentication UI.", + ], + }), + }; + } + if (classification.kind !== "pristine") { + return blocked( + options, + root, + projectPath, + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 + ? "existing-authentication-flow" + : "missing-placeholder", + "Existing or customized application UI was preserved. Integrate AuthView manually in the app's signed-out route.", + sourceDetails, + ); + } + if ( + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 || + target.swift.environmentConsumers.length > 0 + ) { + return blocked( + options, + root, + projectPath, + "existing-authentication-flow", + "Existing Clerk authentication source was preserved instead of layering a second prebuilt flow over it.", + sourceDetails, + ); + } + if (!options.allowDirty) { + const dirty = await gitDirtyState(root, sourceSnapshotValue.absolutePath); + if (dirty === "dirty") { + return blocked( + options, + root, + projectPath, + "dirty-source", + `The planned Swift source ${sourcePath} has existing Git changes; pass the explicit dirty-file override to include it.`, + sourceDetails, + ); + } + if (dirty === "unknown") { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + `Git state for the planned Swift source ${sourcePath} could not be verified.`, + sourceDetails, + ); + } + } + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "ready", { + ...sourceDetails, + actions: [ + `Replace only the untouched SwiftUI placeholder in ${sourcePath} with ClerkKitUI's documented UserButton and AuthView presentation.`, + "Present AuthView from UserButton's signed-out content.", + "Prefetch Clerk images for the prebuilt authentication UI.", + ], + }), + }; +} + +export async function planIOSPrebuiltAuth( + options: IOSPrebuiltAuthPlanOptions, +): Promise { + return (await preparePlan(options)).plan; +} + +function mutationWithHiddenBytes( + snapshot: SourceSnapshot, + candidateBytes: Uint8Array, + boundary: IOSFileMutationBoundary, +): IOSPrebuiltAuthFileMutation { + const mutation = { + absolutePath: snapshot.absolutePath, + expectedHash: snapshot.hash, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: snapshot.mode, + } as IOSPrebuiltAuthFileMutation; + Object.defineProperties(mutation, { + boundary: { value: boundary, enumerable: false }, + originalBytes: { value: snapshot.bytes, enumerable: false }, + candidateBytes: { value: candidateBytes, enumerable: false }, + }); + return mutation; +} + +function readyPrepared( + plan: IOSPrebuiltAuthPlan, + mutation: IOSPrebuiltAuthFileMutation, + validator: () => Promise, +): PreparedIOSPrebuiltAuthMutation { + const prepared = { status: "ready", plan } as PreparedIOSPrebuiltAuthMutation; + Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + preparedValidators.set(prepared, validator); + return prepared; +} + +export async function prepareIOSPrebuiltAuthMutation( + plan: IOSPrebuiltAuthPlan, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-prebuilt-auth" || + !plan.appSourcePath || + !plan.expectedAppSourceHash || + !plan.sourcePath || + !plan.expectedSourceHash + ) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [ + { + code: "invalid-selection", + message: "The prebuilt AuthView source plan is incomplete or unsupported.", + }, + ], + }, + }; + } + const current = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: plan.allowDirty, + }); + if (current.plan.status === "blocked" || !current.sourceSnapshot) { + return { status: "blocked", plan: current.plan }; + } + if ( + current.plan.appSourcePath !== plan.appSourcePath || + current.plan.sourcePath !== plan.sourcePath || + current.plan.expectedAppSourceHash !== plan.expectedAppSourceHash || + current.plan.expectedSourceHash !== plan.expectedSourceHash + ) { + return { + status: "stale", + plan, + message: "The selected Swift sources changed after the preview.", + }; + } + if (current.plan.status === "satisfied") return { status: "satisfied", plan: current.plan }; + + const newline = current.sourceSnapshot.newline; + const generated = `${current.sourceHeader ?? ""}${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; + const candidateBytes = new TextEncoder().encode(generated); + const boundary = await prepareIOSFileMutationBoundary( + plan.root, + current.sourceSnapshot.absolutePath, + ); + if (!boundary) { + return { + status: "stale", + plan, + message: "The selected Swift source moved outside its prepared project boundary.", + }; + } + const mutation = mutationWithHiddenBytes(current.sourceSnapshot, candidateBytes, boundary); + const candidateHash = mutation.candidateHash; + return readyPrepared(plan, mutation, async () => { + const verified = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return ( + verified.plan.status === "satisfied" && + verified.plan.sourcePath === plan.sourcePath && + verified.plan.expectedSourceHash === candidateHash + ); + }); +} + +export async function validatePreparedIOSPrebuiltAuth( + prepared: PreparedIOSPrebuiltAuthMutation, +): Promise { + return (await preparedValidators.get(prepared)?.()) ?? false; +} + +function asExistingMutation(mutation: IOSPrebuiltAuthFileMutation): IOSExistingFileMutation { + return { + path: mutation.absolutePath, + boundary: mutation.boundary, + originalBytes: mutation.originalBytes, + originalHash: mutation.expectedHash, + candidateBytes: mutation.candidateBytes, + candidateHash: mutation.candidateHash, + mode: mutation.mode, + }; +} + +export async function applyIOSPrebuiltAuth( + plan: IOSPrebuiltAuthPlan, +): Promise { + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + if (prepared.status !== "ready") return prepared; + const result = await applyIOSFileTransaction( + [asExistingMutation(prepared.mutation)], + [async () => validatePreparedIOSPrebuiltAuth(prepared)], + ); + if (result.status === "applied") return { status: "applied", plan }; + if (result.status === "stale") { + return { + status: "stale", + plan, + message: "The selected Swift source changed while the approved update was being committed.", + }; + } + return { + status: "rolled-back", + plan, + message: "The AuthView source update failed validation and the original file was restored.", + }; +} diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index 4f89e80de..ef9aa0186 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -21,7 +21,9 @@ function target(): IOSAppTarget { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], openURLHandlers: [], diff --git a/packages/cli-core/src/commands/init/ios/products.ts b/packages/cli-core/src/commands/init/ios/products.ts index ab0f6dd9d..30e7913ce 100644 --- a/packages/cli-core/src/commands/init/ios/products.ts +++ b/packages/cli-core/src/commands/init/ios/products.ts @@ -22,7 +22,23 @@ export function shouldInstallClerkKitUI(target: IOSAppTarget): boolean { return clerkKitUIInstallDecision(target) === "prebuilt"; } -/** Existing custom runtime-key routes that direct source configuration must preserve. */ +/** + * A custom publishable-key source is structurally usable only when the + * selected app has one unambiguous configure call at startup. The expression + * itself remains opaque and is never inspected or compared. + */ +export function hasSupportedIOSCustomConfigure(target: IOSAppTarget): boolean { + const configureCalls = target.swift.configureCalls; + return ( + target.swift.evidenceComplete && + target.swift.status !== "ambiguous" && + configureCalls.length === 1 && + configureCalls[0]?.publishableKeyWiring === "custom" && + configureCalls[0].startupBinding === "app-init" + ); +} + +/** Existing custom configuration that direct source setup must preserve. */ export function hasIOSDirectConfigCompatibility( inspection: IOSProjectInspectionResult, target: IOSAppTarget, diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts new file mode 100644 index 000000000..e991f203b --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -0,0 +1,403 @@ +export interface SwiftSourceRange { + start: number; + end: number; +} + +export interface SwiftUIAppTypeRange extends SwiftSourceRange { + declarationStart: number; + openingBrace: number; + closingBrace: number; +} + +export interface SwiftUISceneBodyRange extends SwiftSourceRange { + declarationStart: number; + openingBrace: number; + closingBrace: number; +} + +export interface SwiftUIRootExpression extends SwiftSourceRange { + containerStart: number; + modifierStarts: number[]; +} + +export interface SwiftUIAppRootStructure { + appType: SwiftUIAppTypeRange; + body: SwiftUISceneBodyRange; + root: SwiftUIRootExpression; + clerkEnvironment: { found: boolean; conflicting: boolean }; +} + +export type SwiftUIAppRootInspection = + | { status: "proven"; structure: SwiftUIAppRootStructure } + | { status: "unsupported-app" } + | { status: "unsupported-body" } + | { status: "unsupported-scene" }; + +interface StructuralIndex { + braceDepth: Int32Array; + conditionalRanges: SwiftSourceRange[]; +} + +function skipWhitespace(source: string, start: number, end = source.length): number { + let cursor = start; + while (cursor < end && /\s/.test(source[cursor] ?? "")) cursor += 1; + return cursor; +} + +function trimWhitespaceEnd(source: string, start: number, end: number): number { + let cursor = end; + while (cursor > start && /\s/.test(source[cursor - 1] ?? "")) cursor -= 1; + return cursor; +} + +function matchingDelimiter( + source: string, + opening: number, + openCharacter: "(" | "{" | "[", + closeCharacter: ")" | "}" | "]", +): number | undefined { + if (source[opening] !== openCharacter) return undefined; + let depth = 0; + for (let index = opening; index < source.length; index += 1) { + if (source[index] === openCharacter) depth += 1; + if (source[index] !== closeCharacter) continue; + depth -= 1; + if (depth === 0) return index; + } + return undefined; +} + +function matchingBrace(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "{", "}"); +} + +function matchingParenthesis(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "(", ")"); +} + +function structuralIndex(source: string): StructuralIndex { + const braceDepth = new Int32Array(source.length + 1); + for (let position = 0; position < source.length; position += 1) { + braceDepth[position + 1] = + braceDepth[position]! + (source[position] === "{" ? 1 : source[position] === "}" ? -1 : 0); + } + + const conditionalRanges: SwiftSourceRange[] = []; + const directive = /^[\t ]*#(if|elseif|else|endif)\b/gm; + let depth = 0; + let rangeStart: number | undefined; + let match: RegExpExecArray | null; + while ((match = directive.exec(source)) !== null) { + if (match[1] === "if") { + if (depth === 0) rangeStart = match.index; + depth += 1; + } + if (match[1] === "endif" && depth > 0) { + depth -= 1; + if (depth === 0 && rangeStart != null) { + conditionalRanges.push({ start: rangeStart, end: match.index }); + rangeStart = undefined; + } + } + } + if (rangeStart != null) conditionalRanges.push({ start: rangeStart, end: source.length }); + return { braceDepth, conditionalRanges }; +} + +function braceDepthAt(index: StructuralIndex, openingBrace: number, position: number): number { + return index.braceDepth[position]! - index.braceDepth[openingBrace]!; +} + +function isInsideConditionalCompilation(index: StructuralIndex, position: number): boolean { + return index.conditionalRanges.some((range) => position >= range.start && position < range.end); +} + +function appTypeRange(source: string, index: StructuralIndex): SwiftUIAppTypeRange | undefined { + const mainMatches = [...source.matchAll(/@main\b/g)]; + if (mainMatches.length !== 1 || mainMatches[0]?.index == null) return undefined; + const mainIndex = mainMatches[0].index; + if (isInsideConditionalCompilation(index, mainIndex) || braceDepthAt(index, 0, mainIndex) !== 0) { + return undefined; + } + + let cursor = mainIndex + mainMatches[0][0].length; + while (true) { + cursor = skipWhitespace(source, cursor); + const attribute = /^@[A-Za-z_][A-Za-z0-9_.]*/.exec(source.slice(cursor)); + if (attribute) { + cursor += attribute[0].length; + cursor = skipWhitespace(source, cursor); + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null) return undefined; + cursor = closing + 1; + } + continue; + } + const modifier = /^(?:public|internal|private|fileprivate|final|nonisolated)\b/.exec( + source.slice(cursor), + ); + if (!modifier) break; + cursor += modifier[0].length; + } + + cursor = skipWhitespace(source, cursor); + const declaration = /^struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(source.slice(cursor)); + if (!declaration) return undefined; + const headerStart = cursor + declaration[0].length; + const openingBrace = source.indexOf("{", headerStart); + if (openingBrace === -1) return undefined; + const header = source.slice(headerStart, openingBrace); + if (/[;{}<>]/.test(header) || /\bwhere\b/.test(header)) return undefined; + const inheritance = /^\s*:\s*([A-Za-z0-9_.,\s]+)\s*$/.exec(header)?.[1]; + if (!inheritance || !inheritance.split(",").some((item) => item.trim() === "App")) { + return undefined; + } + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) return undefined; + if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(source.slice(openingBrace, closingBrace))) { + return undefined; + } + return { + start: mainIndex, + end: closingBrace + 1, + declarationStart: cursor, + openingBrace, + closingBrace, + }; +} + +function bodyRange( + source: string, + appType: SwiftUIAppTypeRange, + index: StructuralIndex, +): SwiftUISceneBodyRange | undefined { + const candidates: SwiftUISceneBodyRange[] = []; + const pattern = /\bvar\s+body\s*:\s*some\s+Scene\b/g; + pattern.lastIndex = appType.openingBrace + 1; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null && match.index < appType.closingBrace) { + if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; + const openingBrace = skipWhitespace(source, match.index + match[0].length); + if (source[openingBrace] !== "{") continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null || closingBrace > appType.closingBrace) continue; + const declarationLineStart = source.lastIndexOf("\n", Math.max(0, match.index - 1)) + 1; + if (source.slice(declarationLineStart, match.index).trim() !== "") continue; + candidates.push({ + start: match.index, + end: closingBrace + 1, + declarationStart: declarationLineStart, + openingBrace, + closingBrace, + }); + pattern.lastIndex = closingBrace + 1; + } + return candidates.length === 1 ? candidates[0] : undefined; +} + +function identifierEnd(source: string, start: number): number | undefined { + const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(start)); + return match ? start + match[0].length : undefined; +} + +function consumeBalancedSuffix(source: string, cursor: number, limit: number): number | undefined { + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null || closing >= limit) return undefined; + cursor = skipWhitespace(source, closing + 1, limit); + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + cursor = closureEnd + 1; + } + return cursor; + } + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + return closureEnd + 1; + } + return undefined; +} + +function rootExpression( + source: string, + start: number, + end: number, + containerStart: number, +): SwiftUIRootExpression | undefined { + let cursor = skipWhitespace(source, start, end); + const expressionStart = cursor; + let identifier = identifierEnd(source, cursor); + if (identifier == null) return undefined; + cursor = identifier; + while (true) { + const beforeDot = skipWhitespace(source, cursor, end); + if (source[beforeDot] !== ".") break; + const memberStart = skipWhitespace(source, beforeDot + 1, end); + identifier = identifierEnd(source, memberStart); + if (identifier == null) return undefined; + const afterMember = skipWhitespace(source, identifier, end); + if (source[afterMember] === "(" || source[afterMember] === "{") break; + cursor = identifier; + } + cursor = skipWhitespace(source, cursor, end); + const primaryEnd = consumeBalancedSuffix(source, cursor, end); + if (primaryEnd == null) return undefined; + cursor = primaryEnd; + + const modifierStarts: number[] = []; + while (true) { + cursor = skipWhitespace(source, cursor, end); + if (source[cursor] !== ".") break; + const modifierStart = cursor; + const nameStart = skipWhitespace(source, cursor + 1, end); + const nameEnd = identifierEnd(source, nameStart); + if (nameEnd == null) return undefined; + cursor = skipWhitespace(source, nameEnd, end); + const suffixEnd = consumeBalancedSuffix(source, cursor, end); + if (suffixEnd == null) return undefined; + modifierStarts.push(modifierStart); + cursor = suffixEnd; + } + cursor = skipWhitespace(source, cursor, end); + if (cursor !== end) return undefined; + return { + start: expressionStart, + end: trimWhitespaceEnd(source, expressionStart, end), + containerStart, + modifierStarts, + }; +} + +function windowGroupRoot( + source: string, + body: SwiftUISceneBodyRange, +): SwiftUIRootExpression | undefined { + let cursor = skipWhitespace(source, body.openingBrace + 1, body.closingBrace); + const windowGroupStart = cursor; + if (!source.slice(cursor).startsWith("WindowGroup")) return undefined; + const wordEnd = cursor + "WindowGroup".length; + if (/[A-Za-z0-9_]/.test(source[wordEnd] ?? "")) return undefined; + cursor = skipWhitespace(source, wordEnd, body.closingBrace); + if (source[cursor] === "(") { + const closingParenthesis = matchingParenthesis(source, cursor); + if (closingParenthesis == null || closingParenthesis >= body.closingBrace) return undefined; + cursor = skipWhitespace(source, closingParenthesis + 1, body.closingBrace); + } + if (source[cursor] !== "{") return undefined; + const groupClosingBrace = matchingBrace(source, cursor); + if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; + let sceneCursor = groupClosingBrace + 1; + while (true) { + sceneCursor = skipWhitespace(source, sceneCursor, body.closingBrace); + if (source[sceneCursor] !== ".") break; + const nameStart = skipWhitespace(source, sceneCursor + 1, body.closingBrace); + const nameEnd = identifierEnd(source, nameStart); + if (nameEnd == null) return undefined; + sceneCursor = skipWhitespace(source, nameEnd, body.closingBrace); + const suffixEnd = consumeBalancedSuffix(source, sceneCursor, body.closingBrace); + if (suffixEnd == null) return undefined; + sceneCursor = suffixEnd; + } + if (skipWhitespace(source, sceneCursor, body.closingBrace) !== body.closingBrace) { + return undefined; + } + const expressionStart = skipWhitespace(source, cursor + 1, groupClosingBrace); + const expressionEnd = trimWhitespaceEnd(source, expressionStart, groupClosingBrace); + if (expressionStart === expressionEnd) return undefined; + return rootExpression(source, expressionStart, expressionEnd, windowGroupStart); +} + +function modifierDetails( + source: string, + root: SwiftUIRootExpression, + modifierStart: number, +): + | { name: string; openingParenthesis?: number; closingParenthesis?: number; body?: string } + | undefined { + const remainder = source.slice(modifierStart, root.end); + const name = /^\.\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(remainder)?.[1]; + if (!name) return undefined; + const nameEnd = modifierStart + (remainder.indexOf(name) + name.length); + const suffixStart = skipWhitespace(source, nameEnd, root.end); + if (source[suffixStart] === "(") { + const closingParenthesis = matchingParenthesis(source, suffixStart); + if (closingParenthesis == null || closingParenthesis > root.end) return { name }; + const closureStart = skipWhitespace(source, closingParenthesis + 1, root.end); + const closureEnd = + source[closureStart] === "{" ? matchingBrace(source, closureStart) : undefined; + return { + name, + openingParenthesis: suffixStart, + closingParenthesis, + body: + closureEnd == null + ? source.slice(suffixStart + 1, closingParenthesis) + : source.slice(closureStart + 1, closureEnd), + }; + } + if (source[suffixStart] === "{") { + const closureEnd = matchingBrace(source, suffixStart); + return closureEnd == null + ? { name } + : { name, body: source.slice(suffixStart + 1, closureEnd) }; + } + return { name }; +} + +function clerkEnvironment( + source: string, + root: SwiftUIRootExpression, +): { found: boolean; conflicting: boolean } { + let found = false; + let conflicting = false; + for (const modifierStart of root.modifierStarts) { + const modifier = modifierDetails(source, root, modifierStart); + if (modifier?.name !== "environment") continue; + if (modifier.openingParenthesis == null || modifier.closingParenthesis == null) { + conflicting = true; + continue; + } + const argumentsSource = source.slice( + modifier.openingParenthesis + 1, + modifier.closingParenthesis, + ); + if (/^\s*Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { + found = true; + } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { + conflicting = true; + } + } + return { found, conflicting }; +} + +/** + * Proves only the narrow shipping SwiftUI root that Clerk can reason about + * deterministically: one unconditional top-level `@main` App, one + * `body: some Scene`, one WindowGroup, and one direct root expression. + */ +export function inspectSwiftUIAppRootWithStatus(source: string): SwiftUIAppRootInspection { + const index = structuralIndex(source); + const appType = appTypeRange(source, index); + if (!appType) return { status: "unsupported-app" }; + const body = bodyRange(source, appType, index); + if (!body) return { status: "unsupported-body" }; + const root = windowGroupRoot(source, body); + if (!root) return { status: "unsupported-scene" }; + return { + status: "proven", + structure: { + appType, + body, + root, + clerkEnvironment: clerkEnvironment(source, root), + }, + }; +} + +export function inspectSwiftUIAppRoot(source: string): SwiftUIAppRootStructure | undefined { + const result = inspectSwiftUIAppRootWithStatus(source); + return result.status === "proven" ? result.structure : undefined; +} diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 353ee8ace..2deb14d49 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -517,6 +517,36 @@ Clerk.configure(publishableKey: key)`, expect(JSON.stringify(inspection)).not.toContain("must-not-leak"); }); + test("rejects EnvironmentValues overloads as Clerk environment injections", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-environment-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + + for (const keyPath of ["\\.self", ".self"]) { + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView().environment(${keyPath}, Clerk.shared) + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.environmentInjections).toEqual([]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + expect(inspection.status).toBe("partial"); + } + }); + test("retains only decoded metadata for a valid inline publishable key", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); @@ -724,6 +754,207 @@ Clerk.configure(publishableKey: key)`, expect(inspection.openURLHandlers).toEqual([{ path: "ClerkCallback.swift" }]); }); + test("distinguishes broad Clerk modifiers from the proven shipping SwiftUI root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const appPath = join(root, "App.swift"); + const helperPath = join(root, "UnusedHelper.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { ContentView() } + } + }`, + ); + await Bun.write( + helperPath, + `import ClerkKit + import SwiftUI + struct UnusedHelper: View { + var body: some View { + Text("Unused") + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: appPath, relativePath: "App.swift" }, + { absolutePath: helperPath, relativePath: "UnusedHelper.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.environmentInjections).toEqual([{ path: "UnusedHelper.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + expect(inspection.openURLHandlers).toEqual([{ path: "UnusedHelper.swift" }]); + expect(inspection.status).toBe("partial"); + }); + + test("proves Clerk environment injection only on the unique WindowGroup root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + init() { Clerk.configure(publishableKey: "pk_test_redacted") } + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + } + func beginMagicLink(_ signIn: SignIn) async throws { + try await signIn.sendEmailLink() + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([{ path: "App.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); + expect(inspection.status).toBe("complete"); + }); + + test("proves the WindowGroup root through a macOS scene modifier", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-swift-root-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + .defaultSize(width: 1100, height: 800) + .windowResizability(.contentSize) + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([{ path: "App.swift" }]); + }); + + test("does not prove a root when selected-target source evidence is incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-incomplete-")); + temporaryDirectories.push(root); + const appPath = join(root, "App.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: appPath, relativePath: "App.swift" }, + { absolutePath: join(root, "Missing.swift"), relativePath: "Missing.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.appRootEvidence).toEqual([]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + }); + + test("recognizes the Auth email-link convenience API as an authentication flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-magic-link-")); + temporaryDirectories.push(root); + const path = join(root, "MagicLink.swift"); + await Bun.write( + path, + `import ClerkKit + func beginMagicLink() async throws { + try await Clerk.shared.auth.signInWithEmailLink(emailAddress: "person@example.com") + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "MagicLink.swift" }, + ]); + + expect(inspection.authFlowReferences).toEqual([{ path: "MagicLink.swift" }]); + }); + + test("does not prove an ambiguous, unsupported, or sanitized-decoy app root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const firstPath = join(root, "First.swift"); + const secondPath = join(root, "Second.swift"); + await Bun.write( + firstPath, + `import ClerkKit + import SwiftUI + // .environment(Clerk.shared).onOpenURL { try await Clerk.shared.handle(url) } + let decoy = #/signIn.sendEmailLink()/# + @main struct First: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + }`, + ); + await Bun.write( + secondPath, + `import ClerkKit + import SwiftUI + @main struct Second: App { + var body: some Scene { WindowGroup { ContentView() } } + }`, + ); + + const ambiguous = await inspectSwiftSources([ + { absolutePath: firstPath, relativePath: "First.swift" }, + { absolutePath: secondPath, relativePath: "Second.swift" }, + ]); + expect(ambiguous.status).toBe("ambiguous"); + expect(ambiguous.appRootEvidence).toEqual([]); + expect(ambiguous.rootEnvironmentInjections).toEqual([]); + + await Bun.write( + secondPath, + `import ClerkKit + import SwiftUI + @main struct Unsupported: App { + var body: some Scene { + WindowGroup { ContentView() } + .defaultSize(width: 1100, height: 800) + Settings { Text("Settings") } + } + }`, + ); + const unsupported = await inspectSwiftSources([ + { absolutePath: secondPath, relativePath: "Second.swift" }, + ]); + expect(unsupported.appRootEvidence).toEqual([]); + expect(unsupported.rootEnvironmentInjections).toEqual([]); + }); + test("recognizes native Clerk auth calls without matching unrelated sign-in APIs", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 2dce01844..9e1e88c58 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -7,6 +7,7 @@ import type { IOSSourceEvidence, IOSSwiftInspection, } from "./types.ts"; +import { inspectSwiftUIAppRoot } from "./swift-app-root.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -14,8 +15,9 @@ const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; -const CLERK_ENVIRONMENT_INJECTION = - /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; +const CLERK_EMAIL_LINK_AUTH_FLOW = + /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; +const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; const CLERK_KIT_IMPORT = @@ -96,6 +98,7 @@ const CLERK_EVIDENCE_PATTERNS = [ CLERK_CONFIGURE_CALL, CLERK_URL_HANDLER, CLERK_NATIVE_AUTH_FLOW, + CLERK_EMAIL_LINK_AUTH_FLOW, CLERK_ENVIRONMENT_INJECTION, CLERK_ENVIRONMENT_CONSUMER, CLERK_AUTH_VIEW, @@ -759,7 +762,9 @@ export async function inspectSwiftSources( const importsClerkKit: IOSSourceEvidence[] = []; const importsClerkKitUI: IOSSourceEvidence[] = []; const configureCalls: IOSConfigureCallEvidence[] = []; + const appRootEvidence: IOSSourceEvidence[] = []; const environmentInjections: IOSSourceEvidence[] = []; + const rootEnvironmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; const openURLHandlers: IOSSourceEvidence[] = []; @@ -799,8 +804,10 @@ export async function inspectSwiftSources( const importsUI = has(sanitized, CLERK_KIT_UI_IMPORT); const importsClerkModule = importsKit || importsUI; if (hasConditionalSetupEvidence(uncertain, importsClerkModule)) evidenceComplete = false; + const appRoot = structuralSource.complete ? inspectSwiftUIAppRoot(sanitized) : undefined; if (has(sanitized, /@main\b/)) entryPoints.push(evidence); + if (appRoot) appRootEvidence.push(evidence); if (importsKit) importsClerkKit.push(evidence); if (importsUI) importsClerkKitUI.push(evidence); if (importsClerkModule) { @@ -809,12 +816,20 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_INJECTION)) { environmentInjections.push(evidence); } + if ( + importsClerkModule && + appRoot?.clerkEnvironment.found && + !appRoot.clerkEnvironment.conflicting + ) { + rootEnvironmentInjections.push(evidence); + } if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { environmentConsumers.push(evidence); } if ( (importsUI && has(sanitized, CLERK_AUTH_VIEW)) || - (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW)) + (importsClerkModule && + (has(sanitized, CLERK_NATIVE_AUTH_FLOW) || has(sanitized, CLERK_EMAIL_LINK_AUTH_FLOW))) ) { authFlowReferences.push(evidence); } @@ -823,6 +838,14 @@ export async function inspectSwiftSources( } } + const hasUniqueProvenAppRoot = + evidenceComplete && + entryPoints.length === 1 && + appRootEvidence.length === 1 && + appRootEvidence[0]?.path === entryPoints[0]?.path; + const provenAppRootEvidence = hasUniqueProvenAppRoot ? appRootEvidence : []; + const provenRootEnvironmentInjections = hasUniqueProvenAppRoot ? rootEnvironmentInjections : []; + const anyClerkEvidence = importsClerkKit.length + importsClerkKitUI.length + @@ -834,7 +857,7 @@ export async function inspectSwiftSources( const status = entryPoints.length > 1 ? "ambiguous" - : configureCalls.length > 0 && environmentInjections.length > 0 + : configureCalls.length > 0 && provenRootEnvironmentInjections.length > 0 ? "complete" : anyClerkEvidence ? "partial" @@ -847,7 +870,9 @@ export async function inspectSwiftSources( importsClerkKit, importsClerkKitUI, configureCalls, + appRootEvidence: provenAppRootEvidence, environmentInjections, + rootEnvironmentInjections: provenRootEnvironmentInjections, environmentConsumers, authFlowReferences, openURLHandlers, diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index cf60ac50f..d86b21071 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -25,6 +25,7 @@ export interface IOSDiagnostic { | "xcode.external-path" | "xcode.generated-project" | "xcode.incomplete-source-membership" + | "xcode.incomplete-container-discovery" | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key"; @@ -119,9 +120,15 @@ export interface IOSSwiftInspection { importsClerkKit: IOSSourceEvidence[]; importsClerkKitUI: IOSSourceEvidence[]; configureCalls: IOSConfigureCallEvidence[]; + /** Unique selected-target @main SwiftUI App with a structurally proven WindowGroup root. */ + appRootEvidence: IOSSourceEvidence[]; + /** Broad lexical evidence retained for diagnostics and conflict detection only. */ environmentInjections: IOSSourceEvidence[]; + /** Clerk environment injection directly attached to the proven shipping WindowGroup root. */ + rootEnvironmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; authFlowReferences: IOSSourceEvidence[]; + /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; } @@ -189,7 +196,6 @@ export type IOSSetupStepId = | "install-clerk-sdk" | "configure-publishable-key" | "inject-clerk-environment" - | "wire-auth-callbacks" | "register-native-application" | "enable-native-apple" | "add-associated-domain" diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 247e02776..d3fe78ada 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -117,6 +117,25 @@ export const ERROR_CODE = { OAUTH_NO_CODE: "oauth_no_code", /** The loopback callback server could not bind a local port. */ CALLBACK_BIND_FAILED: "callback_bind_failed", + + /** No single native iOS application target or Bundle ID could be resolved safely. */ + IOS_TARGET_UNRESOLVED: "ios_target_unresolved", + /** The inspected iOS project has a known condition that prevents safe automatic setup. */ + IOS_SETUP_BLOCKED: "ios_setup_blocked", + /** The iOS worktree cannot be proven safe to modify. */ + IOS_WORKTREE_UNSAFE: "ios_worktree_unsafe", + /** The iOS project or Clerk application changed after the approved setup was planned. */ + IOS_SETUP_STALE: "ios_setup_stale", + /** An internally inconsistent or incomplete iOS setup plan reached the apply boundary. */ + IOS_SETUP_PLAN_INVALID: "ios_setup_plan_invalid", + /** An approved local iOS transaction could not be applied or verified. */ + IOS_LOCAL_APPLY_FAILED: "ios_local_apply_failed", + /** A failed iOS transaction could not restore every original file safely. */ + IOS_LOCAL_ROLLBACK_FAILED: "ios_local_rollback_failed", + /** The development publishable key required by the approved iOS setup is unavailable. */ + IOS_PUBLISHABLE_KEY_UNAVAILABLE: "ios_publishable_key_unavailable", + /** The iOS runtime publishable key does not belong to the linked Clerk application. */ + IOS_PUBLISHABLE_KEY_MISMATCH: "ios_publishable_key_mismatch", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE];