From 97c7b0a48bfd6c45c8af8bd966417d05da9c1dc7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 20:28:34 -0400 Subject: [PATCH 01/10] feat: support Xcode JSON project format --- .changeset/xcode-json-project-format.md | 5 + bun.lock | 5 +- packages/cli-core/package.json | 1 + .../commands/init/ios/apple-entitlement.ts | 6 +- .../src/commands/init/ios/apply-cli.test.ts | 62 +- .../cli-core/src/commands/init/ios/apply.ts | 49 +- .../commands/init/ios/associated-domain.ts | 27 + .../src/commands/init/ios/build-settings.ts | 32 +- .../src/commands/init/ios/discovery.ts | 71 +- .../src/commands/init/ios/dry-run.test.ts | 28 + .../init/ios/entitlements-inspection.ts | 252 +++++ .../init/ios/entitlements-settings.test.ts | 130 ++- .../init/ios/entitlements-settings.ts | 891 +++++++++++++++++- .../src/commands/init/ios/inspect.test.ts | 87 ++ .../cli-core/src/commands/init/ios/inspect.ts | 388 +++----- .../src/commands/init/ios/install-sdk.test.ts | 69 +- .../src/commands/init/ios/install-sdk.ts | 466 ++++++--- .../src/commands/init/ios/macos-network.ts | 6 +- .../src/commands/init/ios/project-adapter.ts | 28 + .../init/ios/project-document.test.ts | 68 ++ .../src/commands/init/ios/project-document.ts | 72 ++ .../src/commands/init/ios/test-helpers.ts | 11 +- .../cli-core/src/commands/init/ios/types.ts | 4 +- .../init/ios/xcproj-build-settings.test.ts | 270 ++++++ .../init/ios/xcproj-build-settings.ts | 306 ++++++ .../src/commands/init/ios/xcproj-inspect.ts | 675 +++++++++++++ .../commands/init/ios/xcproj-install-sdk.ts | 559 +++++++++++ .../src/commands/init/ios/xcproj.test.ts | 221 +++++ .../cli-core/src/commands/init/ios/xcproj.ts | 460 +++++++++ .../ios-json/MyApp.xcodeproj/project.xcproj | 59 ++ .../fixtures/ios-json/MyApp/ContentView.swift | 17 + .../ios-json/MyApp/MyApp.entitlements | 7 + .../fixtures/ios-json/MyApp/MyAppApp.swift | 10 + test/e2e/fixtures/ios-json/README.md | 17 + 34 files changed, 4920 insertions(+), 439 deletions(-) create mode 100644 .changeset/xcode-json-project-format.md create mode 100644 packages/cli-core/src/commands/init/ios/entitlements-inspection.ts create mode 100644 packages/cli-core/src/commands/init/ios/project-adapter.ts create mode 100644 packages/cli-core/src/commands/init/ios/project-document.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/project-document.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj-inspect.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/xcproj.ts create mode 100644 test/e2e/fixtures/ios-json/MyApp.xcodeproj/project.xcproj create mode 100644 test/e2e/fixtures/ios-json/MyApp/ContentView.swift create mode 100644 test/e2e/fixtures/ios-json/MyApp/MyApp.entitlements create mode 100644 test/e2e/fixtures/ios-json/MyApp/MyAppApp.swift create mode 100644 test/e2e/fixtures/ios-json/README.md diff --git a/.changeset/xcode-json-project-format.md b/.changeset/xcode-json-project-format.md new file mode 100644 index 000000000..04613a2da --- /dev/null +++ b/.changeset/xcode-json-project-format.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Support inspecting, configuring, and verifying Apple projects that use Xcode's JSON project format. diff --git a/bun.lock b/bun.lock index c0b89e1c7..73f051100 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ }, "packages/cli": { "name": "clerk", - "version": "3.2.0", + "version": "3.3.0", "bin": { "clerk": "./bin/clerk", }, @@ -41,6 +41,7 @@ "commander": "^15.0.0", "env-paths": "^4.0.0", "external-editor": "^3.1.0", + "jsonc-parser": "^3.3.1", "magicast": "^0.5.3", "semver": "^7.8.5", "yaml": "^2.9.0", @@ -500,6 +501,8 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 989f3d89b..fc988d21e 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -26,6 +26,7 @@ "commander": "^15.0.0", "env-paths": "^4.0.0", "external-editor": "^3.1.0", + "jsonc-parser": "^3.3.1", "magicast": "^0.5.3", "semver": "^7.8.5", "yaml": "^2.9.0" diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index 4a60f2869..417a7c36c 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -22,6 +22,7 @@ import { } from "./entitlements-settings.ts"; import { isRecord } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import { xcodeProjectDocumentPath } from "./project-document.ts"; import type { IOSNativePlatform } from "./types.ts"; const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; @@ -780,7 +781,10 @@ export async function prepareIOSAppleEntitlementMutation( ); } const entitlementsPath = resolve(plan.root, createFile.path); - const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const pbxprojPath = await xcodeProjectDocumentPath(resolve(plan.root, plan.projectPath)); + if (!pbxprojPath) { + return blockPrepared(plan, "invalid-plan", "The selected Xcode project document is missing."); + } const baseEntitlements = baseByPath.get(entitlementsPath); const basePbx = baseByPath.get(pbxprojPath); if (baseEntitlements && !isCreateMutation(baseEntitlements)) { diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index afe4aa9a3..44b0ecdfe 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -15,6 +15,7 @@ import { convertIOSFixtureToSynchronizedMissingEntitlements, convertIOSFixtureToSynchronizedRoot, createIOSFixture, + createIOSJSONFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; @@ -45,6 +46,55 @@ setDefaultTimeout(15_000); beforeEach(resetApplyCLITestRemoteState); afterEach(cleanupApplyCLITestState); +test("keeps Xcode JSON init, rerun, and Doctor in agreement", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-apply-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const configDir = await createIsolatedCLIState(); + const args = [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + ]; + + const first = await runCLI(root, args, configDir); + if (first.exitCode !== 0) throw new Error(`${first.stdout}\n${first.stderr}`); + expect(first.exitCode).toBe(0); + expect(`${first.stdout}\n${first.stderr}`).not.toContain(authFixtureKey); + + const applied = await treeDigest(root); + const inspection = await inspectIOSProject(root); + expect(inspection.projects[0]).toMatchObject({ projectFormat: "xcproj" }); + expect(inspection.appTargets[0]).toMatchObject({ + packages: { package: "remote", clerkKit: "linked" }, + swift: { status: "complete" }, + }); + const remoteAfterFirst = currentNativeRemoteState(); + expect(remoteAfterFirst).toMatchObject({ + nativeAPIEnabled: true, + iosApplications: [{ app_id_prefix: "LEGACY1234", bundle_id: "com.example.MyApp" }], + mutations: { + nativeSettingsPatchCount: 1, + iosApplicationPostCount: 1, + appleConfigPatchCount: 0, + }, + }); + expectCanonicalIOSDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); + + const second = await runCLI(root, args, configDir); + expect(second.exitCode).toBe(0); + expect(await treeDigest(root)).toEqual(applied); + expect(currentNativeRemoteState()).toEqual(remoteAfterFirst); + expectCanonicalIOSDoctorChecksToPass((await auditCurrentNativeFixture(root)).results); +}); + async function convertFixtureToUnsandboxedMultiplatform(root: string): Promise { const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); const project = await Bun.file(projectPath).text(); @@ -127,7 +177,7 @@ async function auditCurrentNativeFixture(root: string) { return runIOSDoctorChecks(doctorContext(), { root, target: "MyApp" }, dependencies); } -function expectAutomatedDoctorChecksToPass( +function expectCanonicalIOSDoctorChecksToPass( results: Awaited>["results"], ): void { for (const expectedName of [ @@ -135,7 +185,6 @@ function expectAutomatedDoctorChecksToPass( "iOS: Configure Clerk with a publishable key", "iOS: Inject Clerk into the SwiftUI environment", "iOS: Add Clerk's associated domain", - "macOS: Allow outgoing network access", "iOS: Linked development key", "iOS: Native Application", ]) { @@ -153,6 +202,15 @@ function expectAutomatedDoctorChecksToPass( }); } +function expectAutomatedDoctorChecksToPass( + results: Awaited>["results"], +): void { + expectCanonicalIOSDoctorChecksToPass(results); + expect( + results.find((result) => result.name === "macOS: Allow outgoing network access"), + ).toMatchObject({ status: "pass" }); +} + async function linkedProductFilters(root: string, productName: "ClerkKit" | "ClerkKitUI") { const project = parsePbxProject( await Bun.file(join(root, "MyApp.xcodeproj", "project.pbxproj")).text(), diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 9d5f41e4c..7cf53ef5f 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -303,6 +303,17 @@ export async function applyIOSLocalSetup( ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } + const selectedProject = inspection.projects.find( + (project) => project.path === selection.projectPath, + ); + if (!selectedProject) { + throw iosSetupError( + "The selected native Apple target no longer has a readable Xcode project document.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const projectDocumentAbsolutePath = resolve(options.root, selectedProject.projectFilePath); + const projectDocumentDisplayPath = selectedProject.projectFilePath; if (!selectedTarget.platformEvidenceComplete) { throw iosSetupError( `${selectedTargetPlatformBlockerDescription( @@ -488,8 +499,8 @@ export async function applyIOSLocalSetup( 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`, + absolutePath: projectDocumentAbsolutePath, + displayPath: projectDocumentDisplayPath, }); } if (directConfigNeedsWrite(directConfigPlan) && directConfigPlan?.sourcePath) { @@ -507,8 +518,8 @@ export async function applyIOSLocalSetup( if (associatedDomainNeedsWrite(associatedDomainPlan)) { if (associatedDomainPlan.missingEntitlementsSettings) { plannedPaths.push({ - absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), - displayPath: `${selection.projectPath}/project.pbxproj`, + absolutePath: projectDocumentAbsolutePath, + displayPath: projectDocumentDisplayPath, }); } for (const file of associatedDomainPlan.files) { @@ -521,8 +532,8 @@ export async function applyIOSLocalSetup( if (macOSNetworkCapabilityPlan?.status === "ready") { if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { plannedPaths.push({ - absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), - displayPath: `${selection.projectPath}/project.pbxproj`, + absolutePath: projectDocumentAbsolutePath, + displayPath: projectDocumentDisplayPath, }); } for (const file of macOSNetworkCapabilityPlan.files) { @@ -535,8 +546,8 @@ export async function applyIOSLocalSetup( if (appleEntitlementPlan?.status === "ready") { if (appleEntitlementPlan.missingEntitlementsSettings) { plannedPaths.push({ - absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), - displayPath: `${selection.projectPath}/project.pbxproj`, + absolutePath: projectDocumentAbsolutePath, + displayPath: projectDocumentDisplayPath, }); } for (const file of appleEntitlementPlan.files) { @@ -552,8 +563,8 @@ export async function applyIOSLocalSetup( ) { if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { plannedPaths.push({ - absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), - displayPath: `${selection.projectPath}/project.pbxproj`, + absolutePath: projectDocumentAbsolutePath, + displayPath: projectDocumentDisplayPath, }); } for (const file of prebuiltAuthAppleEntitlementPlan.files) { @@ -603,7 +614,7 @@ export async function applyIOSLocalSetup( log.info(`\nclerk init will perform the following read-only ${platformLabel} verification:\n`); } if (installPlan.status === "ready") { - log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); for (const action of installPlan.actions) log.info(` ${action}`); } if (directConfigPlan) { @@ -630,7 +641,7 @@ export async function applyIOSLocalSetup( } if (associatedDomainNeedsWrite(associatedDomainPlan)) { if (associatedDomainPlan.missingEntitlementsSettings && installPlan.status !== "ready") { - log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); } for (const file of associatedDomainPlan.files) { log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); @@ -650,7 +661,7 @@ export async function applyIOSLocalSetup( installPlan.status !== "ready" && !associatedDomainPlan?.missingEntitlementsSettings ) { - log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); } for (const file of macOSNetworkCapabilityPlan.files) { log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); @@ -674,7 +685,7 @@ export async function applyIOSLocalSetup( !associatedDomainPlan?.missingEntitlementsSettings && !macOSNetworkCapabilityPlan?.missingEntitlementsSettings ) { - log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + log.info(` ${yellow("MODIFY")} ${projectDocumentDisplayPath}`); } for (const file of appleEntitlementPlan.files) { if (!alreadyPreviewedEntitlements.has(file.path)) { @@ -696,24 +707,24 @@ export async function applyIOSLocalSetup( ); const alreadyPreviewedPaths = new Set(); if (installPlan.status === "ready") { - alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + alreadyPreviewedPaths.add(projectDocumentDisplayPath); } if (associatedDomainNeedsWrite(associatedDomainPlan)) { if (associatedDomainPlan.missingEntitlementsSettings) { - alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + alreadyPreviewedPaths.add(projectDocumentDisplayPath); } for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); } if (macOSNetworkCapabilityPlan?.status === "ready") { if (macOSNetworkCapabilityPlan.missingEntitlementsSettings) { - alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + alreadyPreviewedPaths.add(projectDocumentDisplayPath); } for (const file of macOSNetworkCapabilityPlan.files) { alreadyPreviewedPaths.add(file.path); } } if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { - const projectFile = `${selection.projectPath}/project.pbxproj`; + const projectFile = projectDocumentDisplayPath; if (!alreadyPreviewedPaths.has(projectFile)) { log.info(` ${yellow("MODIFY")} ${projectFile}`); } @@ -1124,7 +1135,7 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { /** * Commits a previously previewed iOS setup after authentication. Fresh direct - * configuration combines project.pbxproj and the Swift entry source in one + * configuration combines the selected Xcode project document and Swift entry source in one * guarded local transaction. Existing custom key sources are preserved and * are never rewritten or interpreted. */ 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 a3a17c477..1c7f49baf 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -26,12 +26,14 @@ import { import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; import type { IOSAppTarget, IOSDiagnostic, IOSNativePlatform, IOSProjectInspectionResult, } from "./types.ts"; +import { parseXCProjSource, xcprojTargets } from "./xcproj.ts"; const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains"; const MAX_ENTITLEMENTS_BYTES = 1_000_000; @@ -427,6 +429,31 @@ async function ownershipIsExclusive( const inventory = await discoverLocalIOSProjects(root, [selectedProject]); if (!inventory.complete) return false; for (const absoluteProject of inventory.projectPaths) { + const documentResolution = await resolveXcodeProjectDocument(absoluteProject); + if (documentResolution.status !== "found") return false; + if (documentResolution.document.format === "xcproj") { + if (!(await pathIsSafelyWithinIOSRoot(root, documentResolution.document.absolutePath))) { + return false; + } + const projectFile = await readBoundedRegularFile( + documentResolution.document.absolutePath, + 15_000_000, + ); + if (projectFile.status !== "ok") return false; + const targets = xcprojTargets(parseXCProjSource(projectFile.bytes).root); + // The canonical JSON project path is safe when it has only the + // selected app target. Additional JSON targets are preserved but left + // for manual review until their non-application entitlement ownership + // can be modeled with the same guarantees as PBX targets. + if ( + absoluteProject !== selectedProject || + targets.length !== 1 || + targets[0]?.id !== selectedTargetId + ) { + return false; + } + continue; + } const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) return false; const info = await lstat(pbxprojPath); diff --git a/packages/cli-core/src/commands/init/ios/build-settings.ts b/packages/cli-core/src/commands/init/ios/build-settings.ts index 8cfb15a55..3f2a93759 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.ts @@ -629,7 +629,7 @@ function configurationReferences( async function settingsForConfiguration( root: string, - projectPath: string, + projectDocumentRelativePath: string, projectDirectory: string, groupRootDirectory: string, configuration: PbxObject | undefined, @@ -666,7 +666,7 @@ async function settingsForConfiguration( remedy: "Use a literal checked-in base xcconfig path before automating setup.", evidence: [ { - path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + path: projectDocumentRelativePath, objectId: baseReference, keyPath: "baseConfigurationReference", }, @@ -691,7 +691,7 @@ async function settingsForConfiguration( remedy: "Repair the base xcconfig file reference before automating setup.", evidence: [ { - path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + path: projectDocumentRelativePath, objectId: baseReference, keyPath: "baseConfigurationReference", }, @@ -796,12 +796,12 @@ function resolveSettingAcrossContexts( function missingConfiguration( root: string, - projectPath: string, + projectDocumentPath: string, configurationId: string, platform: IOSNativePlatform = "ios", ): InspectedTargetConfiguration { const evidence: IOSSourceEvidence = { - path: relativeIOSPath(root, resolve(projectPath, "project.pbxproj")), + path: relativeIOSPath(root, projectDocumentPath), objectId: configurationId, keyPath: "buildConfigurations", }; @@ -828,6 +828,8 @@ function missingConfiguration( export async function inspectTargetBuildConfigurations(options: { root: string; projectPath: string; + /** Defaults to the legacy project.pbxproj document inside projectPath. */ + projectDocumentPath?: string; groupRootDirectory: string; projectObject: PbxObject; targetId: string; @@ -841,6 +843,7 @@ export async function inspectTargetBuildConfigurations(options: { const { root, projectPath, + projectDocumentPath = resolve(projectPath, "project.pbxproj"), groupRootDirectory, projectObject, targetId, @@ -851,12 +854,12 @@ export async function inspectTargetBuildConfigurations(options: { platform: requestedPlatform, } = options; const projectDirectory = dirname(projectPath); - const pbxprojRelativePath = relativeIOSPath(root, resolve(projectPath, "project.pbxproj")); + const projectDocumentRelativePath = relativeIOSPath(root, projectDocumentPath); const projectConfigurationReferences = configurationReferences( asString(projectObject.buildConfigurationList), objects, diagnostics, - pbxprojRelativePath, + projectDocumentRelativePath, "PBXProject", ); const projectConfigsByName = new Map( @@ -869,7 +872,7 @@ export async function inspectTargetBuildConfigurations(options: { asString(targetObject.buildConfigurationList), objects, diagnostics, - pbxprojRelativePath, + projectDocumentRelativePath, `Target ${targetName}`, ); const inspected: InspectedTargetConfiguration[] = []; @@ -880,7 +883,12 @@ export async function inspectTargetBuildConfigurations(options: { const targetConfig = targetReference.object; if (!targetConfig) { inspected.push( - missingConfiguration(root, projectPath, targetReference.id, requestedPlatform ?? "ios"), + missingConfiguration( + root, + projectDocumentPath, + targetReference.id, + requestedPlatform ?? "ios", + ), ); continue; } @@ -895,7 +903,7 @@ export async function inspectTargetBuildConfigurations(options: { }; const projectSettings = await settingsForConfiguration( root, - projectPath, + projectDocumentRelativePath, projectDirectory, groupRootDirectory, projectConfigsByName.get(name), @@ -908,7 +916,7 @@ export async function inspectTargetBuildConfigurations(options: { ); const evaluation = await settingsForConfiguration( root, - projectPath, + projectDocumentRelativePath, projectDirectory, groupRootDirectory, targetConfig, @@ -949,7 +957,7 @@ export async function inspectTargetBuildConfigurations(options: { if (evaluatedContexts.length === 0) continue; const evidence = (setting: string): IOSSourceEvidence => ({ - path: pbxprojRelativePath, + path: projectDocumentRelativePath, objectId: targetId, keyPath: `buildConfigurations.${name}.buildSettings.${setting}`, }); diff --git a/packages/cli-core/src/commands/init/ios/discovery.ts b/packages/cli-core/src/commands/init/ios/discovery.ts index bacac75d5..f02aa5220 100644 --- a/packages/cli-core/src/commands/init/ios/discovery.ts +++ b/packages/cli-core/src/commands/init/ios/discovery.ts @@ -9,7 +9,9 @@ import { type PbxObjects, } from "./pbx.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; import type { IOSWorkspaceInspection } from "./types.ts"; +import { MAX_XCPROJ_BYTES, parseXCProjSource, xcprojArray, xcprojRecord } from "./xcproj.ts"; const MAX_DISCOVERY_DEPTH = 3; const MAX_EXHAUSTIVE_DISCOVERY_DEPTH = 24; @@ -226,7 +228,74 @@ async function referencedProjectsForProject( return { projectPaths: [], complete: false, valid: false }; } - const pbxprojPath = resolve(projectPath, "project.pbxproj"); + const documentResolution = await resolveXcodeProjectDocument(projectPath); + if (documentResolution.status !== "found") { + return { projectPaths: [], complete: false, valid: true }; + } + if (documentResolution.document.format === "xcproj") { + const documentPath = documentResolution.document.absolutePath; + if (!(await pathIsSafelyWithinIOSRoot(root, documentPath))) { + return { projectPaths: [], complete: false, valid: true }; + } + const projectFile = await readBoundedRegularFile(documentPath, MAX_XCPROJ_BYTES); + if (projectFile.status !== "ok") { + return { projectPaths: [], complete: false, valid: true }; + } + try { + const parsed = parseXCProjSource(projectFile.bytes); + const projectPaths = new Set(); + let complete = true; + const visit = (value: unknown, parentDirectory: string): void => { + let reference: Record; + try { + reference = xcprojRecord(value); + } catch { + complete = false; + return; + } + const path = typeof reference.path === "string" ? reference.path : ""; + const kind = typeof reference.kind === "string" ? reference.kind : "file"; + if (kind === "group") { + const groupDirectory = path + ? path.startsWith("/") + ? resolve(dirname(projectPath), path.slice("/".length)) + : resolve(parentDirectory, path) + : parentDirectory; + try { + for (const child of xcprojArray(reference.children ?? [])) { + visit(child, groupDirectory); + } + } catch { + complete = false; + } + return; + } + if (!path.endsWith(".xcodeproj")) return; + if (/^<(?:PRODUCTS|SDK|DEVELOPER)>\//.test(path)) return; + const absolutePath = path.startsWith("/") + ? resolve(dirname(projectPath), path.slice("/".length)) + : resolve(parentDirectory, path); + if (!isWithinRoot(root, absolutePath)) { + complete = false; + return; + } + projectPaths.add(absolutePath); + }; + for (const reference of xcprojArray(parsed.root.files)) { + visit(reference, dirname(projectPath)); + } + return { + projectPaths: [...projectPaths].sort(), + complete, + valid: true, + canonicalProjectPath, + }; + } catch { + return { projectPaths: [], complete: false, valid: true }; + } + } + + const pbxprojPath = documentResolution.document.absolutePath; if (!(await pathIsSafelyWithinIOSRoot(root, pbxprojPath))) { return { projectPaths: [], complete: false, valid: true }; } diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index d7d63e39b..9ab923cdb 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { convertIOSFixtureToSynchronizedMissingEntitlements, createIOSFixture, + createIOSJSONFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; @@ -72,6 +73,33 @@ async function runCLI(root: string, args: string[], env: Record { + test("inspects an Xcode JSON project without changing its bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-dry-run-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + schemaVersion: 1, + mode: "read-only", + inspection: { + platform: "ios", + projects: [{ projectFormat: "xcproj" }], + selection: { state: "selected", targetName: "MyApp" }, + }, + plan: { kind: "clerk-ios-setup", status: "action-required" }, + }); + expect(await treeDigest(root)).toEqual(before); + }); + test("non-TTY mode emits JSON without network requests or local/global writes", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/entitlements-inspection.ts b/packages/cli-core/src/commands/init/ios/entitlements-inspection.ts new file mode 100644 index 000000000..5290b6782 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/entitlements-inspection.ts @@ -0,0 +1,252 @@ +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; +import { resolveEntitlementsAbsolutePath, type EntitlementBuildContext } from "./build-settings.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { asString, isRecord } from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; +import type { + IOSBuildConfiguration, + IOSDiagnostic, + IOSEntitlementsInspection, + IOSNativePlatform, + IOSSourceEvidence, +} from "./types.ts"; + +const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; +const MAX_ENTITLEMENTS_BYTES = 2_000_000; + +function appleEntitlementState( + parsed: Record, +): IOSEntitlementsInspection["signInWithAppleState"] { + if (!Object.hasOwn(parsed, APPLE_SIGN_IN_KEY)) return "absent"; + const value = parsed[APPLE_SIGN_IN_KEY]; + return Array.isArray(value) && value.length === 1 && value[0] === "Default" ? "exact" : "invalid"; +} + +export async function inspectEntitlements( + root: string, + absolutePath: string, + platform: IOSNativePlatform, + evidence: IOSSourceEvidence[], + diagnostics: IOSDiagnostic[], +): Promise { + const relativePath = relativeIOSPath(root, absolutePath); + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "missing") { + diagnostics.push({ + code: "xcode.missing-entitlements", + severity: "warning", + message: `The configured entitlements file does not exist: ${relativePath}`, + remedy: "Create the file in Xcode or update CODE_SIGN_ENTITLEMENTS.", + evidence, + }); + return undefined; + } + + try { + if (file.status !== "ok") throw new Error("unreadable entitlements"); + const bytes = file.bytes; + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + throw new Error("binary plist"); + } + const parsed = parseIOSPlist(new TextDecoder().decode(bytes)); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + + const associatedDomainsKey = "com.apple.developer.associated-domains"; + const rawAssociatedDomains = parsed[associatedDomainsKey]; + if ( + Object.hasOwn(parsed, associatedDomainsKey) && + (!Array.isArray(rawAssociatedDomains) || + !rawAssociatedDomains.every((value): value is string => typeof value === "string")) + ) { + diagnostics.push({ + code: "xcode.invalid-associated-domains", + severity: "warning", + message: `${relativePath} has an invalid Associated Domains entitlement value.`, + remedy: `Set ${associatedDomainsKey} to an array containing only strings, then rerun the inspector.`, + evidence: [{ path: relativePath, keyPath: associatedDomainsKey }], + }); + } + const associatedDomains = + Array.isArray(rawAssociatedDomains) && + rawAssociatedDomains.every((value): value is string => typeof value === "string") + ? rawAssociatedDomains + : []; + const applicationIdentifier = asString( + parsed[platform === "macos" ? "com.apple.application-identifier" : "application-identifier"], + ); + const signInWithAppleState = appleEntitlementState(parsed); + if (signInWithAppleState === "invalid") { + diagnostics.push({ + code: "xcode.invalid-apple-entitlement", + severity: "warning", + message: `${relativePath} has an invalid Sign in with Apple entitlement value.`, + remedy: `Set ${APPLE_SIGN_IN_KEY} to an array containing only Default, then rerun the inspector.`, + evidence: [{ path: relativePath, keyPath: APPLE_SIGN_IN_KEY }], + }); + } + return { + path: relativePath, + associatedDomains: associatedDomains.sort((left, right) => left.localeCompare(right)), + unresolvedAssociatedDomains: [], + applicationIdentifier, + teamIdentifier: asString(parsed["com.apple.developer.team-identifier"]), + signInWithAppleState, + signInWithApple: signInWithAppleState === "exact", + }; + } catch { + diagnostics.push({ + code: "xcode.unreadable-entitlements", + severity: "warning", + message: `Could not inspect entitlements at ${relativePath}. Only XML plist entitlements are read in portable mode.`, + remedy: "Open the file in Xcode and save it as XML, then rerun the inspector.", + evidence, + }); + return undefined; + } +} + +export async function attachEntitlements( + root: string, + projectPath: string, + platform: IOSNativePlatform, + configurations: IOSBuildConfiguration[], + contextsByConfiguration: Map, + diagnostics: IOSDiagnostic[], +): Promise { + const cache = new Map(); + for (const configuration of configurations) { + if (configuration.entitlementsPath.state !== "resolved") continue; + const absolutePath = resolveEntitlementsAbsolutePath( + root, + projectPath, + configuration.entitlementsPath, + ); + if (!absolutePath) { + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS outside the inspected root.`, + evidence: configuration.entitlementsPath.evidence, + }); + continue; + } + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + diagnostics.push({ + code: "xcode.external-path", + severity: "warning", + message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS through a path outside the inspected root.`, + evidence: configuration.entitlementsPath.evidence, + }); + continue; + } + if (!cache.has(absolutePath)) { + cache.set( + absolutePath, + await inspectEntitlements( + root, + absolutePath, + platform, + configuration.entitlementsPath.evidence, + diagnostics, + ), + ); + } + const entitlements = cache.get(absolutePath); + if (!entitlements) continue; + + const contexts = contextsByConfiguration.get(configuration.name) ?? []; + const resolvedAssociatedDomains: string[] = []; + const unresolvedAssociatedDomains: string[] = []; + for (const domain of entitlements.associatedDomains) { + const expansions = contexts.map((context) => expandEntitlementDomain(domain, context)); + const resolved = expansions.filter((value): value is string => value != null); + if ( + contexts.length > 0 && + resolved.length === contexts.length && + new Set(resolved).size === 1 + ) { + resolvedAssociatedDomains.push(resolved[0]!); + } else { + unresolvedAssociatedDomains.push(domain); + } + } + if (unresolvedAssociatedDomains.length > 0) { + diagnostics.push({ + code: "xcode.unresolved-build-setting", + severity: "warning", + message: `${configuration.name} has associated-domain values with unresolved build settings.`, + remedy: + "Resolve the variables in the entitlements configuration before relying on domain checks.", + evidence: configuration.entitlementsPath.evidence, + }); + } + + const applicationIdentifier = entitlements.applicationIdentifier; + const prefixMatch = /^([A-Z0-9]{10})\.(.+)$/.exec(applicationIdentifier ?? ""); + const literalAppIdentifierPrefix = + prefixMatch && + configuration.bundleIdentifier.state === "resolved" && + bundleIdentifiersEqual(prefixMatch[2], configuration.bundleIdentifier.value) + ? prefixMatch[1] + : undefined; + configuration.entitlements = { + ...entitlements, + associatedDomains: resolvedAssociatedDomains.sort(), + unresolvedAssociatedDomains: unresolvedAssociatedDomains.sort(), + ...(literalAppIdentifierPrefix ? { literalAppIdentifierPrefix } : {}), + }; + } +} + +export function expandEntitlementDomain( + raw: string, + context: EntitlementBuildContext, +): string | undefined { + const variable = /\$\(([^)]+)\)|\$\{([^}]+)\}/g; + const resolving = new Set(); + const expand = (value: string, depth: number): string | undefined => { + if (depth > 20) return undefined; + let unresolved = false; + variable.lastIndex = 0; + const expanded = value.replace(variable, (_match, parenthesized, braced) => { + const name = String(parenthesized ?? braced); + if (name.includes(":")) { + unresolved = true; + return ""; + } + const settingName = + context.settings[name] == null && name === "CFBundleIdentifier" + ? "PRODUCT_BUNDLE_IDENTIFIER" + : name; + if (resolving.has(settingName)) { + unresolved = true; + return ""; + } + const taints = [ + ...(context.settingTaints.get(settingName) ?? []), + ...(context.globalTaintOverrides.has(settingName) ? [] : context.globalTaints), + ]; + if (taints.length > 0) { + unresolved = true; + return ""; + } + const replacement = context.settings[settingName] ?? context.builtins[settingName]; + if (replacement == null) { + unresolved = true; + return ""; + } + resolving.add(settingName); + const nested = expand(replacement, depth + 1); + resolving.delete(settingName); + if (nested == null) unresolved = true; + return nested ?? ""; + }); + variable.lastIndex = 0; + return unresolved || variable.test(expanded) ? undefined : expanded; + }; + + const expanded = expand(raw, 0)?.trim(); + if (!expanded || /pk_(?:test|live)_/i.test(expanded)) return undefined; + return expanded; +} 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 e2498bc16..40be909d2 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 @@ -32,7 +32,8 @@ import { validateIOSSDKInstallPostcondition, } from "./install-sdk.ts"; import type { PbxObjects } from "./pbx.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; +import { createIOSFixture, createIOSJSONFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; +import { applyXCProjValue, parseXCProjSource, xcprojTargets } from "./xcproj.ts"; const SYNCHRONIZED_ROOT_ID = "515151515151515151515151"; const ANCESTOR_SYNCHRONIZED_ROOT_ID = "525252525252525252525252"; @@ -60,6 +61,10 @@ function entitlementsPath(root: string): string { return join(root, "MyApp", "MyApp.entitlements"); } +function xcprojPath(root: string): string { + return join(root, "MyApp.xcodeproj", "project.xcproj"); +} + function mutableProject(source: string): MutableProject { const project = parsePbxProject(source); const archive = project as unknown as { objects: PbxObjects }; @@ -184,6 +189,129 @@ afterEach(async () => { }); describe("missing iOS entitlements build settings", () => { + test("recognizes an existing Xcode JSON entitlements setting", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + + const plan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "satisfied", + projectFormat: "xcproj", + targetName: "MyApp", + entitlementsPath: "MyApp/MyApp.entitlements", + buildSettingPath: "MyApp/MyApp.entitlements", + synchronizedRootPath: "MyApp", + configurationIds: ["Debug", "Release"], + blockers: [], + }); + }); + + test("adds Xcode JSON entitlements settings transactionally and reruns byte-identically", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + const path = xcprojPath(root); + const withoutEntitlementsSetting = applyXCProjValue( + await readFile(path, "utf8"), + ["targets", 0, "build-settings", "CODE_SIGN_ENTITLEMENTS"], + undefined, + ); + await writeFile(path, withoutEntitlementsSetting); + await rm(entitlementsPath(root)); + await chmod(path, 0o640); + + const plan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + expect(plan).toMatchObject({ + status: "ready", + projectFormat: "xcproj", + entitlementsPath: "MyApp/MyApp.entitlements", + buildSettingPath: "MyApp/MyApp.entitlements", + synchronizedRootObjectId: "xcproj-folder:0", + blockers: [], + }); + const prepared = await prepareIOSMissingEntitlementsSettingsMutation(plan); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + if (prepared.status !== "ready") throw new Error("Expected an Xcode JSON mutation."); + expect(prepared.mutation.path).toBe(path); + + const result = await applyIOSExistingFileTransaction( + [prepared.mutation], + [() => validateIOSMissingEntitlementsSettingsPostcondition(plan)], + ); + expect(result.status).toBe("applied"); + expect((await stat(path)).mode & 0o777).toBe(0o640); + const after = await readFile(path); + const parsed = parseXCProjSource(after); + expect(xcprojTargets(parsed.root)[0]?.buildSettings).toMatchObject({ + [DEVICE_SETTING]: "MyApp/MyApp.entitlements", + [SIMULATOR_SETTING]: "MyApp/MyApp.entitlements", + }); + + const rerun = await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + expect(rerun.status).toBe("satisfied"); + expect((await prepareIOSMissingEntitlementsSettingsMutation(rerun)).status).toBe("satisfied"); + expect(await readFile(path)).toEqual(after); + }); + + test("composes Xcode JSON SDK and entitlements edits into one project mutation", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + const path = xcprojPath(root); + await writeFile( + path, + applyXCProjValue( + await readFile(path, "utf8"), + ["targets", 0, "build-settings", "CODE_SIGN_ENTITLEMENTS"], + undefined, + ), + ); + await rm(entitlementsPath(root)); + const selected = { + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }; + const sdk = await prepareIOSSDKInstallMutation(await planIOSSDKInstall(selected)); + const entitlementsPlan = await planIOSMissingEntitlementsSettings(selected); + expect(sdk.status).toBe("ready"); + expect(entitlementsPlan.status).toBe("ready"); + if (sdk.status !== "ready") throw new Error("Expected an SDK mutation."); + + const combined = await prepareIOSMissingEntitlementsSettingsMutation( + entitlementsPlan, + sdk.mutation, + ); + expect(combined.status).toBe("ready"); + if (combined.status !== "ready") throw new Error("Expected a combined mutation."); + expect(combined.mutation.path).toBe(path); + expect(combined.mutation.originalHash).toBe(sdk.mutation.originalHash); + expect(combined.mutation.candidateHash).not.toBe(sdk.mutation.candidateHash); + + const result = await applyIOSExistingFileTransaction( + [combined.mutation], + [ + () => validateIOSSDKInstallPostcondition(sdk.plan), + () => validateIOSMissingEntitlementsSettingsPostcondition(entitlementsPlan), + ], + ); + expect(result.status).toBe("applied"); + expect(await validateIOSSDKInstallPostcondition(sdk.plan)).toBe(true); + expect(await validateIOSMissingEntitlementsSettingsPostcondition(entitlementsPlan)).toBe(true); + }); + test("adds SDK-qualified settings to every selected configuration and is byte-idempotent", async () => { const root = await makeSynchronizedFixture({ secondTarget: true }); await chmod(pbxprojPath(root), 0o640); 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 585e01ffa..79782532f 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util"; import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; +import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; import { discoverLocalIOSProjects, pathIsSafelyWithinIOSRoot, @@ -14,6 +15,7 @@ import { type IOSExistingFileMutation, } from "./file-transaction.ts"; import { inspectIOSProject } from "./inspect.ts"; +import { resolveXcodeProjectDocument, type XcodeProjectDocumentRef } from "./project-document.ts"; import { asString, buildPbxParentIndex, @@ -23,6 +25,17 @@ import { type PbxObjects, } from "./pbx.ts"; import type { IOSDiagnostic, IOSNativePlatform } from "./types.ts"; +import { + applyXCProjValue, + parseXCProjSource, + type XCProjRecord, + type XCProjTarget, + xcprojArray, + xcprojRecord, + xcprojString, + xcprojStringArray, + xcprojTargets, +} from "./xcproj.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const DEVICE_SETTING = "CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"; @@ -78,6 +91,7 @@ interface IOSMissingEntitlementsSettingsPlanBase { } interface IOSMissingEntitlementsSettingsResolvedFields { + projectFormat: "pbxproj" | "xcproj"; targetName: string; /** Invocation-root-relative destination. */ entitlementsPath: string; @@ -100,6 +114,7 @@ export type IOSMissingEntitlementsSettingsPlan = Partial & { status: "blocked" }); interface IOSMissingEntitlementsSettingsPlanSource { + projectFormat?: "pbxproj" | "xcproj"; targetName?: string; entitlementsPath?: string; buildSettingPath?: string; @@ -136,6 +151,19 @@ interface ProjectSnapshot { graph: ProjectGraph; } +interface XCProjProjectSnapshot { + absoluteProjectPath: string; + documentPath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + document: XCProjRecord; + target: XCProjTarget; + targetIndex: number; + configurationIds: string[]; +} + interface SynchronizedRoot { objectId: string; absolutePath: string; @@ -144,6 +172,262 @@ interface SynchronizedRoot { inode: number; } +const XCPROJ_APP_PRODUCT_TYPES = new Set(["application", APP_PRODUCT_TYPE]); + +function xcprojConfigurationIds(document: XCProjRecord): string[] | undefined { + let values: unknown[]; + try { + values = xcprojArray(document.configurations ?? []); + } catch { + return undefined; + } + const result: string[] = []; + for (const value of values) { + if (typeof value === "string") { + if (!value) return undefined; + result.push(value); + continue; + } + try { + const configuration = xcprojRecord(value); + const name = xcprojString(configuration.name); + if (!name) return undefined; + result.push( + typeof configuration.id === "string" && configuration.id ? configuration.id : name, + ); + } catch { + return undefined; + } + } + return result.length > 0 && new Set(result).size === result.length ? result : undefined; +} + +async function readXCProjSnapshot( + root: string, + documentRef: XcodeProjectDocumentRef, + targetId: string, +): Promise { + if ( + documentRef.format !== "xcproj" || + !(await pathIsSafelyWithinIOSRoot(root, documentRef.projectPath)) || + !(await pathIsSafelyWithinIOSRoot(root, documentRef.absolutePath)) + ) { + return undefined; + } + try { + const [projectInfo, info] = await Promise.all([ + lstat(documentRef.projectPath), + lstat(documentRef.absolutePath), + ]); + if ( + !projectInfo.isDirectory() || + projectInfo.isSymbolicLink() || + !info.isFile() || + info.isSymbolicLink() || + info.size > MAX_PBXPROJ_BYTES + ) { + return undefined; + } + const bytes = new Uint8Array(await readFile(documentRef.absolutePath)); + const parsed = parseXCProjSource(bytes); + const targets = xcprojTargets(parsed.root); + if (new Set(targets.map((target) => target.name)).size !== targets.length) return undefined; + const targetIndex = targets.findIndex((target) => target.id === targetId); + const target = targets[targetIndex]; + const configurationIds = xcprojConfigurationIds(parsed.root); + if ( + !target || + target.kind !== "native" || + !target.productType || + !XCPROJ_APP_PRODUCT_TYPES.has(target.productType) || + !configurationIds + ) { + return undefined; + } + return { + absoluteProjectPath: documentRef.projectPath, + documentPath: documentRef.absolutePath, + bytes, + hash: hashIOSFileBytes(bytes), + mode: info.mode & 0o7777, + source: parsed.source, + document: parsed.root, + target, + targetIndex, + configurationIds, + }; + } catch { + return undefined; + } +} + +function xcprojSnapshotFromBytes( + snapshot: XCProjProjectSnapshot, + bytes: Uint8Array, +): XCProjProjectSnapshot | undefined { + try { + const parsed = parseXCProjSource(bytes); + const targets = xcprojTargets(parsed.root); + if (new Set(targets.map((target) => target.name)).size !== targets.length) return undefined; + const targetIndex = targets.findIndex((target) => target.id === snapshot.target.id); + const target = targets[targetIndex]; + const configurationIds = xcprojConfigurationIds(parsed.root); + if ( + !target || + target.kind !== "native" || + !target.productType || + !XCPROJ_APP_PRODUCT_TYPES.has(target.productType) || + !configurationIds + ) { + return undefined; + } + return { + ...snapshot, + bytes, + source: parsed.source, + document: parsed.root, + target, + targetIndex, + configurationIds, + }; + } catch { + return undefined; + } +} + +function xcprojReferencePath( + parent: string, + path: string, + projectDirectory = parent, +): string | undefined { + if (!path || /^<(?:PRODUCTS|SDK|DEVELOPER)>\//.test(path)) return undefined; + if (path.startsWith("/")) { + return resolve(projectDirectory, path.slice("/".length)); + } + if (path.startsWith("<")) return undefined; + return resolve(parent, path); +} + +async function selectedXCProjSynchronizedRoot( + root: string, + snapshot: XCProjProjectSnapshot, +): Promise<{ root?: SynchronizedRoot; blocker?: IOSMissingEntitlementsSettingsBlocker }> { + const candidates: Array<{ reference: XCProjRecord; path: string; identity: string }> = []; + const projectDirectory = dirname(snapshot.absoluteProjectPath); + let malformed = false; + const visit = (value: unknown, parent: string, identity: string): void => { + let reference: XCProjRecord; + try { + reference = xcprojRecord(value); + } catch { + malformed = true; + return; + } + const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const path = typeof reference.path === "string" ? reference.path : ""; + if (kind === "group") { + const groupDirectory = path ? xcprojReferencePath(parent, path, projectDirectory) : parent; + if (!groupDirectory) { + malformed = true; + return; + } + let children: unknown[]; + try { + children = xcprojArray(reference.children ?? []); + } catch { + malformed = true; + return; + } + children.forEach((child, index) => visit(child, groupDirectory, `${identity}.${index}`)); + return; + } + if (kind !== "folder") return; + let membership: string[]; + try { + membership = xcprojStringArray(reference["target-membership"] ?? []); + } catch { + malformed = true; + return; + } + if (!membership.includes(snapshot.target.name)) return; + if (membership.length !== 1 || reference["membership-exceptions"] !== undefined || !path) { + malformed = true; + return; + } + const absolutePath = xcprojReferencePath(parent, path, projectDirectory); + if (!absolutePath) { + malformed = true; + return; + } + candidates.push({ reference, path: absolutePath, identity }); + }; + try { + for (const [index, reference] of xcprojArray(snapshot.document.files).entries()) { + visit(reference, projectDirectory, `${index}`); + } + } catch { + malformed = true; + } + if (malformed) { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root could not be modeled safely.", + ), + }; + } + if (candidates.length === 0) { + return { + blocker: blocker( + "missing-synchronized-root", + "The selected target does not have a filesystem-synchronized source root.", + ), + }; + } + if (candidates.length !== 1) { + return { + blocker: blocker( + "ambiguous-synchronized-root", + "The selected target has more than one filesystem-synchronized source root.", + ), + }; + } + const candidate = candidates[0]!; + if (!(await pathIsSafelyWithinIOSRoot(root, candidate.path))) { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root resolves outside the invocation root.", + ), + }; + } + try { + const info = await lstat(candidate.path); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("unsupported root"); + await realpath(candidate.path); + const explicitId = + typeof candidate.reference.id === "string" && candidate.reference.id + ? candidate.reference.id + : undefined; + return { + root: { + objectId: explicitId ?? `xcproj-folder:${candidate.identity}`, + absolutePath: candidate.path, + relativePath: relativeIOSPath(root, candidate.path), + device: info.dev, + inode: info.ino, + }, + }; + } catch { + return { + blocker: blocker( + "unsafe-synchronized-root", + "The selected target's synchronized source root must be a regular, non-symlink directory.", + ), + }; + } +} + function blocker( code: IOSMissingEntitlementsSettingsBlockerCode, message: string, @@ -536,6 +820,161 @@ function pathContains(directory: string, candidate: string): boolean { ); } +function entitlementsSettingEntries( + settings: Readonly>, +): Array<[string, unknown]> { + return Object.entries(settings).filter(([key]) => + /^CODE_SIGN_ENTITLEMENTS(?:\[.*\])?$/.test(key), + ); +} + +async function xcprojDestinationOwnershipIsExclusive( + root: string, + inventoryProjectPaths: readonly string[], + snapshot: XCProjProjectSnapshot, + synchronizedRoot: SynchronizedRoot, + destination: string, +): Promise { + if ( + inventoryProjectPaths.length !== 1 || + resolve(inventoryProjectPaths[0]!) !== snapshot.absoluteProjectPath + ) { + // Cross-project ownership is intentionally refused until every referenced + // project format can contribute the same semantic ownership inventory. + return false; + } + + let canonicalDestination: string; + let canonicalSelectedRoot: string; + try { + canonicalDestination = ( + await canonicalPathWithPossibleMissingLeaf(destination) + ).toLocaleLowerCase("en-US"); + canonicalSelectedRoot = (await realpath(synchronizedRoot.absolutePath)).toLocaleLowerCase( + "en-US", + ); + } catch { + return false; + } + + const projectSettings = snapshot.document["build-settings"]; + if (projectSettings !== undefined) { + try { + if (entitlementsSettingEntries(xcprojRecord(projectSettings)).length > 0) return false; + } catch { + return false; + } + } + for (const target of xcprojTargets(snapshot.document)) { + if (target.id === snapshot.target.id) continue; + for (const [, rawValue] of entitlementsSettingEntries(target.buildSettings)) { + if (typeof rawValue !== "string" || rawValue.includes("$(")) return false; + const targetPath = resolve(dirname(snapshot.absoluteProjectPath), rawValue); + if (!(await pathIsSafelyWithinIOSRoot(root, targetPath))) return false; + try { + if ( + (await canonicalPathWithPossibleMissingLeaf(targetPath)).toLocaleLowerCase("en-US") === + canonicalDestination + ) { + return false; + } + } catch { + return false; + } + } + } + + let complete = true; + const projectDirectory = dirname(snapshot.absoluteProjectPath); + const visit = async (value: unknown, parent: string): Promise => { + let reference: XCProjRecord; + try { + reference = xcprojRecord(value); + } catch { + complete = false; + return; + } + const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const path = typeof reference.path === "string" ? reference.path : ""; + if (kind === "group") { + const groupDirectory = path ? xcprojReferencePath(parent, path, projectDirectory) : parent; + if (!groupDirectory) { + complete = false; + return; + } + let children: unknown[]; + try { + children = xcprojArray(reference.children ?? []); + } catch { + complete = false; + return; + } + for (const child of children) await visit(child, groupDirectory); + return; + } + if (kind === "folder") { + const folderPath = xcprojReferencePath(parent, path, projectDirectory); + if (!folderPath || !(await pathIsSafelyWithinIOSRoot(root, folderPath))) { + complete = false; + return; + } + let memberships: string[]; + try { + memberships = xcprojStringArray(reference["target-membership"] ?? []); + } catch { + complete = false; + return; + } + if (reference["membership-exceptions"] !== undefined) { + complete = false; + return; + } + if (memberships.some((name) => name !== snapshot.target.name)) { + try { + const canonicalFolder = (await realpath(folderPath)).toLocaleLowerCase("en-US"); + if ( + canonicalFolder === canonicalSelectedRoot || + pathContains(canonicalFolder, canonicalDestination) + ) { + complete = false; + } + } catch { + complete = false; + } + } + return; + } + if (kind !== "file") { + complete = false; + return; + } + if (!path || /^<(?:PRODUCTS|SDK|DEVELOPER)>\//.test(path)) return; + const referencedPath = xcprojReferencePath(parent, path, projectDirectory); + if (!referencedPath || !(await pathIsSafelyWithinIOSRoot(root, referencedPath))) { + complete = false; + return; + } + try { + if ( + (await canonicalPathWithPossibleMissingLeaf(referencedPath)).toLocaleLowerCase("en-US") === + canonicalDestination + ) { + complete = false; + } + } catch { + complete = false; + } + }; + try { + for (const reference of xcprojArray(snapshot.document.files)) { + await visit(reference, projectDirectory); + } + } catch { + return false; + } + return complete; +} + type GitIgnoreState = "not-repository" | "included" | "ignored" | "error"; async function findGitMarker( @@ -978,6 +1417,66 @@ async function buildSettingState( return "conflicting"; } +function xcprojRawSettingsAreExact( + target: XCProjTarget, + buildSettingPath: string, + platform: IOSNativePlatform, +): boolean { + if (target.buildSettings.CODE_SIGN_ENTITLEMENTS === buildSettingPath) return true; + if (target.buildSettings.CODE_SIGN_ENTITLEMENTS !== undefined) return false; + return entitlementsSettingKeys(platform).every( + (key) => target.buildSettings[key] === buildSettingPath, + ); +} + +async function xcprojBuildSettingState( + root: string, + snapshot: XCProjProjectSnapshot, + buildSettingPath: string, + platform: IOSNativePlatform, +): Promise<"missing" | "exact" | "conflicting" | "incomplete"> { + const diagnostics: IOSDiagnostic[] = []; + let inspected; + try { + inspected = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: snapshot.absoluteProjectPath, + projectDocumentPath: snapshot.documentPath, + project: snapshot.document, + target: snapshot.target, + diagnostics, + platform, + }); + } catch { + return "incomplete"; + } + if ( + inspected.length !== snapshot.configurationIds.length || + inspected.length === 0 || + inspected.some((configuration) => !configuration.platformEvidenceComplete) || + !inspected.every((configuration) => configuration.platform === platform) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return "incomplete"; + } + if ( + inspected.every((configuration) => configuration.model.entitlementsPath.state === "missing") + ) { + return "missing"; + } + if ( + xcprojRawSettingsAreExact(snapshot.target, buildSettingPath, platform) && + inspected.every( + (configuration) => + configuration.model.entitlementsPath.state === "resolved" && + configuration.model.entitlementsPath.value === buildSettingPath, + ) + ) { + return "exact"; + } + return "conflicting"; +} + async function inspectSelectedTarget( root: string, projectPath: string, @@ -1017,8 +1516,222 @@ async function inspectSelectedTarget( : undefined; } -export async function planIOSMissingEntitlementsSettings( - options: IOSMissingEntitlementsSettingsOptions, +async function planXCProjMissingEntitlementsSettings( + options: IOSMissingEntitlementsSettingsOptions & { platform: IOSNativePlatform }, + documentRef: XcodeProjectDocumentRef, +): Promise { + const snapshot = await readXCProjSnapshot(options.root, documentRef, options.targetId); + if (!snapshot) { + return blockedPlan( + options, + blocker( + "unreadable-project", + "The selected project.xcproj is missing, malformed, symlinked, too large, or unreadable.", + ), + ); + } + const selectedTarget = await inspectSelectedTarget( + options.root, + options.projectPath, + options.targetId, + options.platform, + ); + const snapshotFields = { + projectFormat: "xcproj" as const, + expectedPbxprojHash: snapshot.hash, + expectedPbxprojMode: snapshot.mode, + configurationIds: snapshot.configurationIds, + }; + if ( + !selectedTarget || + selectedTarget.platform !== options.platform || + !selectedTarget.supportedPlatforms.includes(options.platform) + ) { + return blockedPlan( + options, + blocker( + "target-not-found", + `The selected object is not the exact inspected native ${options.platform === "macos" ? "macOS" : "iOS"} application target.`, + ), + snapshotFields, + ); + } + if (!selectedTarget.platformEvidenceComplete) { + return blockedPlan( + options, + blocker( + "incomplete-build-configurations", + `Every selected-target build configuration must prove ${options.platform === "macos" ? "macOS" : "iOS"} support before adding entitlements settings.`, + ), + { ...snapshotFields, targetName: selectedTarget.name }, + ); + } + + const synchronized = await selectedXCProjSynchronizedRoot(options.root, snapshot); + if (!synchronized.root) { + return blockedPlan(options, synchronized.blocker!, { + ...snapshotFields, + targetName: selectedTarget.name, + }); + } + const destination = destinationForRoot( + options.root, + snapshot.absoluteProjectPath, + synchronized.root, + options.platform, + options.platform === "macos" && selectedTarget.supportedPlatforms.includes("ios"), + ); + if ("blocker" in destination) { + return blockedPlan(options, destination.blocker, { + ...snapshotFields, + targetName: selectedTarget.name, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + }); + } + + const inventory = await discoverLocalIOSProjects(options.root, [snapshot.absoluteProjectPath]); + if ( + !inventory.complete || + !(await xcprojDestinationOwnershipIsExclusive( + options.root, + inventory.projectPaths, + snapshot, + synchronized.root, + destination.absolutePath, + )) + ) { + return blockedPlan( + options, + blocker( + "shared-synchronized-root", + "The synchronized source root or entitlements destination is shared with another target, or exclusive ownership could not be proven.", + ), + { + ...snapshotFields, + targetName: selectedTarget.name, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + }, + ); + } + if (!(await pathIsSafelyWithinIOSRoot(options.root, destination.absolutePath))) { + return blockedPlan( + options, + blocker( + "invalid-entitlements-destination", + "The entitlements destination resolves outside the invocation root.", + ), + { ...snapshotFields, targetName: selectedTarget.name }, + ); + } + const ignoreState = await gitIgnoreState(destination.absolutePath); + if (ignoreState === "ignored" || ignoreState === "error") { + return blockedPlan( + options, + blocker( + ignoreState === "ignored" ? "ignored-entitlements-destination" : "unresolved-git-ignore", + ignoreState === "ignored" + ? `${destination.relativePath} is ignored by Git. Add a targeted .gitignore negation before automatic setup.` + : `Git ignore status for ${destination.relativePath} could not be verified safely.`, + ), + { ...snapshotFields, targetName: selectedTarget.name }, + ); + } + + const settingState = await xcprojBuildSettingState( + options.root, + snapshot, + destination.buildSettingPath, + options.platform, + ); + const sharedPlanFields: IOSMissingEntitlementsSettingsResolvedFields & { + configurationIds: string[]; + } = { + ...snapshotFields, + targetName: selectedTarget.name, + entitlementsPath: destination.relativePath, + buildSettingPath: destination.buildSettingPath, + synchronizedRootPath: synchronized.root.relativePath, + synchronizedRootObjectId: synchronized.root.objectId, + expectedSynchronizedRootIdentity: { + device: synchronized.root.device, + inode: synchronized.root.inode, + }, + }; + if (settingState === "incomplete" || settingState === "conflicting") { + return blockedPlan( + options, + blocker( + settingState === "incomplete" + ? "incomplete-build-configurations" + : "conflicting-entitlements-settings", + settingState === "incomplete" + ? `Every selected-target build configuration and ${options.platform === "macos" ? "macOS" : "iOS"} build context must be inspectable before adding entitlements settings.` + : `The selected target already has partial, inherited, unresolved, or conflicting ${options.platform === "macos" ? "macOS" : "iOS"} entitlements settings.`, + ), + sharedPlanFields, + ); + } + const pathState = await destinationState(synchronized.root, destination.absolutePath); + if (settingState === "missing") { + if (pathState !== "absent") { + return blockedPlan( + options, + blocker( + "entitlements-destination-exists", + "The intended entitlements destination already exists; it will not be adopted or overwritten.", + ), + sharedPlanFields, + ); + } + const generator = await generatedProjectKind(options.root, snapshot.absoluteProjectPath); + if (generator) { + return blockedPlan( + options, + blocker( + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated project.xcproj output.`, + ), + sharedPlanFields, + ); + } + } else if (pathState === "unsupported" || pathState === "case-collision") { + return blockedPlan( + options, + blocker( + "invalid-entitlements-destination", + "The configured entitlements destination is a symlink, directory, case-colliding path, or unreadable entry.", + ), + sharedPlanFields, + ); + } + return { + ...planBase(options), + ...sharedPlanFields, + status: settingState === "exact" ? "satisfied" : "ready", + actions: + settingState === "exact" + ? [] + : [ + options.platform === "macos" + ? `Add a macOS CODE_SIGN_ENTITLEMENTS setting for ${destination.relativePath} to the selected target.` + : `Add iOS device and simulator CODE_SIGN_ENTITLEMENTS settings for ${destination.relativePath} to the selected target.`, + ], + blockers: [], + }; +} + +export async function planIOSMissingEntitlementsSettings( + options: IOSMissingEntitlementsSettingsOptions, ): Promise { const root = resolve(options.root); const normalizedProjectPath = options.projectPath.replaceAll("\\", "/"); @@ -1038,16 +1751,20 @@ export async function planIOSMissingEntitlementsSettings( ); } const absoluteProjectPath = resolve(root, normalizedProjectPath); - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + const documentResolution = await resolveXcodeProjectDocument(absoluteProjectPath); if ( !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || - !(await pathIsSafelyWithinIOSRoot(root, pbxprojPath)) + documentResolution.status !== "found" || + !(await pathIsSafelyWithinIOSRoot(root, documentResolution.document.absolutePath)) ) { return blockedPlan( normalizedOptions, blocker("external-path", "The selected Xcode project resolves outside the invocation root."), ); } + if (documentResolution.document.format === "xcproj") { + return planXCProjMissingEntitlementsSettings(normalizedOptions, documentResolution.document); + } const snapshot = await readProjectSnapshot(root, normalizedProjectPath, options.targetId); if (!snapshot) { return blockedPlan( @@ -1266,6 +1983,7 @@ export async function planIOSMissingEntitlementsSettings( const sharedPlanFields: IOSMissingEntitlementsSettingsResolvedFields & { configurationIds: string[]; } = { + projectFormat: "pbxproj", targetName, entitlementsPath: destination.relativePath, buildSettingPath: destination.buildSettingPath, @@ -1361,6 +2079,7 @@ function sameResolvedPlanIdentity( return ( left.root === right.root && left.projectPath === right.projectPath && + left.projectFormat === right.projectFormat && left.targetId === right.targetId && left.platform === right.platform && left.entitlementsPath === right.entitlementsPath && @@ -1420,6 +2139,166 @@ function baseMutationIsValid(mutation: IOSExistingFileMutation): boolean { ); } +async function prepareXCProjMissingEntitlementsSettingsMutation( + plan: Extract, + baseMutation?: IOSExistingFileMutation, +): Promise { + const documentResolution = await resolveXcodeProjectDocument( + resolve(plan.root, plan.projectPath), + ); + if ( + documentResolution.status !== "found" || + documentResolution.document.format !== "xcproj" || + !(await pathIsSafelyWithinIOSRoot(plan.root, documentResolution.document.absolutePath)) + ) { + return { status: "stale", plan }; + } + const snapshot = await readXCProjSnapshot(plan.root, documentResolution.document, plan.targetId); + if (!snapshot) return { status: "stale", plan }; + const entitlementsPath = resolve(plan.root, plan.entitlementsPath); + const synchronizedRootPath = resolve(plan.root, plan.synchronizedRootPath); + if ( + snapshot.hash !== plan.expectedPbxprojHash || + snapshot.mode !== plan.expectedPbxprojMode || + !sameStringArray(snapshot.configurationIds, plan.configurationIds) || + !(await pathIsSafelyWithinIOSRoot(plan.root, entitlementsPath)) || + !(await pathIsSafelyWithinIOSRoot(plan.root, synchronizedRootPath)) + ) { + return { status: "stale", plan }; + } + try { + const rootInfo = await lstat(synchronizedRootPath); + if ( + !rootInfo.isDirectory() || + rootInfo.isSymbolicLink() || + rootInfo.dev !== plan.expectedSynchronizedRootIdentity.device || + rootInfo.ino !== plan.expectedSynchronizedRootIdentity.inode + ) { + return { status: "stale", plan }; + } + } catch { + return { status: "stale", plan }; + } + try { + await lstat(entitlementsPath); + return { status: "stale", plan }; + } catch (error) { + if (!isFileSystemError(error, "ENOENT")) return { status: "stale", plan }; + } + const boundary = await prepareIOSFileMutationBoundary(plan.root, snapshot.documentPath); + if (!boundary) return { status: "stale", plan }; + + const replanned = await planIOSMissingEntitlementsSettings({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + platform: plan.platform, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if (replanned.status !== "ready" || !sameResolvedPlanIdentity(plan, replanned)) { + return { status: "stale", plan }; + } + + if (baseMutation) { + if ( + resolve(baseMutation.path) !== snapshot.documentPath || + baseMutation.originalHash !== plan.expectedPbxprojHash || + baseMutation.mode !== plan.expectedPbxprojMode || + !isDeepStrictEqual(baseMutation.boundary, boundary) + ) { + return { status: "stale", plan }; + } + if (!baseMutationIsValid(baseMutation)) { + return blockPrepared( + plan, + "unsupported-project", + "The prepared base Xcode mutation is invalid.", + ); + } + } + + const sourceBytes = baseMutation?.candidateBytes ?? snapshot.bytes; + const candidateSnapshot = xcprojSnapshotFromBytes(snapshot, sourceBytes); + if ( + !candidateSnapshot || + !sameStringArray(candidateSnapshot.configurationIds, plan.configurationIds) + ) { + return blockPrepared( + plan, + "unsupported-project", + "The prepared Xcode JSON candidate changed the selected target structure.", + ); + } + const candidateRoot = await selectedXCProjSynchronizedRoot(plan.root, candidateSnapshot); + if ( + !candidateRoot.root || + candidateRoot.root.objectId !== plan.synchronizedRootObjectId || + candidateRoot.root.absolutePath !== synchronizedRootPath + ) { + return blockPrepared( + plan, + "unsupported-project", + "The prepared Xcode JSON candidate changed the selected synchronized source root.", + ); + } + const beforeState = await xcprojBuildSettingState( + plan.root, + candidateSnapshot, + plan.buildSettingPath, + plan.platform, + ); + if (beforeState !== "missing" && beforeState !== "exact") { + return blockPrepared( + plan, + beforeState === "incomplete" + ? "incomplete-build-configurations" + : "conflicting-entitlements-settings", + "The prepared Xcode JSON candidate has unresolved or conflicting entitlements settings.", + ); + } + + let candidate = candidateSnapshot.source; + try { + for (const key of entitlementsSettingKeys(plan.platform)) { + candidate = applyXCProjValue( + candidate, + ["targets", candidateSnapshot.targetIndex, "build-settings", key], + plan.buildSettingPath, + ); + } + } catch { + return blockPrepared( + plan, + "unsupported-project", + "The proposed Xcode JSON project could not be edited and reparsed safely.", + ); + } + const candidateBytes = new TextEncoder().encode(candidate); + const reparsed = xcprojSnapshotFromBytes(snapshot, candidateBytes); + if ( + !reparsed || + !sameStringArray(reparsed.configurationIds, plan.configurationIds) || + !xcprojRawSettingsAreExact(reparsed.target, plan.buildSettingPath, plan.platform) || + (await xcprojBuildSettingState(plan.root, reparsed, plan.buildSettingPath, plan.platform)) !== + "exact" + ) { + return blockPrepared( + plan, + "unsupported-project", + "The proposed Xcode JSON project did not retain every required entitlements setting.", + ); + } + return preparedWithHiddenMutation(plan, { + path: snapshot.documentPath, + boundary: baseMutation?.boundary ?? boundary, + originalBytes: baseMutation?.originalBytes ?? snapshot.bytes, + originalHash: baseMutation?.originalHash ?? plan.expectedPbxprojHash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: baseMutation?.mode ?? plan.expectedPbxprojMode, + }); +} + export async function prepareIOSMissingEntitlementsSettingsMutation( plan: IOSMissingEntitlementsSettingsPlan, baseMutation?: IOSExistingFileMutation, @@ -1445,6 +2324,7 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( : { status: "stale", plan }; } if ( + !plan.projectFormat || !plan.expectedPbxprojHash || plan.expectedPbxprojMode == null || !plan.entitlementsPath || @@ -1460,6 +2340,9 @@ export async function prepareIOSMissingEntitlementsSettingsMutation( "The serialized entitlements-settings plan is incomplete.", ); } + if (plan.projectFormat === "xcproj") { + return prepareXCProjMissingEntitlementsSettingsMutation(plan, baseMutation); + } const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); const entitlementsPath = resolve(plan.root, plan.entitlementsPath); const synchronizedRootPath = resolve(plan.root, plan.synchronizedRootPath); 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 57c353d26..3988cdcf4 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -11,9 +11,11 @@ import { addVisionOSDestinationsToFixture, convertIOSFixtureToMultiplatform, createIOSFixture, + createIOSJSONFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; +import { applyXCProjValue } from "./xcproj.ts"; const temporaryDirectories: string[] = []; const FILE_TRANSACTION_MODULE = `${import.meta.dir}/file-transaction.ts`; @@ -836,6 +838,91 @@ describe("inspectIOSProject", () => { }); }); + test("inspects an Xcode JSON project through the shared semantic model", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-inspect-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + + const inspection = await inspectIOSProject(root); + + expect(inspection.selection).toEqual({ + state: "selected", + targetId: "C1E000000000000000000001", + targetName: "MyApp", + projectPath: "MyApp.xcodeproj", + platform: "ios", + }); + expect(inspection.projects[0]).toMatchObject({ + path: "MyApp.xcodeproj", + projectFilePath: "MyApp.xcodeproj/project.xcproj", + projectFormat: "xcproj", + packages: [], + }); + expect(inspection.appTargets[0]).toMatchObject({ + name: "MyApp", + platform: "ios", + supportedPlatforms: ["ios"], + platformEvidenceComplete: true, + packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, + swift: { + evidenceComplete: true, + sourceFilesScanned: 2, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + }, + }); + expect(inspection.appTargets[0]?.configurations).toHaveLength(2); + expect(inspection.appTargets[0]?.configurations[0]?.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.MyApp", + }); + expect(inspection.appTargets[0]?.configurations[0]?.entitlements).toMatchObject({ + associatedDomains: ["webcredentials:clerk.example.test"], + literalAppIdentifierPrefix: "LEGACY1234", + teamIdentifier: "ABCDE12345", + }); + expect(inspection.diagnostics).toEqual([]); + }); + + test("resolves project-anchored JSON source references from nested groups", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-project-anchor-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const source = await readFile(projectPath, "utf8"); + await Bun.write( + projectPath, + applyXCProjValue( + source, + ["files"], + [ + { + kind: "group", + path: "Nested", + children: [ + { + path: "/MyApp/MyAppApp.swift", + "target-membership": ["MyApp/compile-sources"], + }, + { + path: "/MyApp/ContentView.swift", + "target-membership": ["MyApp/compile-sources"], + }, + ], + }, + ], + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 2, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + }); + expect(inspection.diagnostics).toEqual([]); + }); + test("extracts the App ID Prefix when Bundle ID casing differs", async () => { const root = await fixture({ complete: true }); const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index a49ce9d5b..94c981eb0 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1,12 +1,9 @@ -import { readdir } from "node:fs/promises"; +import { lstat, readdir } from "node:fs/promises"; import { dirname, extname, relative, resolve, sep } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; -import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; import { addBuildSettingConflictDiagnostics, inspectTargetBuildConfigurations, - resolveEntitlementsAbsolutePath, - type EntitlementBuildContext, } from "./build-settings.ts"; import { readBoundedRegularFile } from "./bounded-file.ts"; import { @@ -16,8 +13,11 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath, } from "./discovery.ts"; +import { attachEntitlements } from "./entitlements-inspection.ts"; import { hasInterruptedIOSFileTransaction } from "./file-transaction.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; +import type { IOSTargetSourceMembership, ParsedIOSProject } from "./project-adapter.ts"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; import { asString, asStringArray, @@ -31,26 +31,22 @@ import { type PbxObjects, type PbxParentIndex, } from "./pbx.ts"; -import { parseIOSPlist } from "./plist.ts"; import { inspectSwiftSources } from "./swift.ts"; +import { inspectXCProjProject } from "./xcproj-inspect.ts"; +import { MAX_XCPROJ_BYTES, parseXCProjSource } from "./xcproj.ts"; import type { IOSAppTarget, - IOSBuildConfiguration, IOSClerkPackageState, IOSDiagnostic, - IOSEntitlementsInspection, IOSNativePlatform, IOSPackageReference, IOSProductLinkState, IOSProjectInspection, IOSProjectInspectionResult, - IOSSourceEvidence, IOSTargetSelection, } from "./types.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; -const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; -const MAX_ENTITLEMENTS_BYTES = 2_000_000; const MAX_PBXPROJ_BYTES = 15_000_000; const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; @@ -65,27 +61,6 @@ const SOURCE_IGNORES = new Set([ "SourcePackages", ]); -interface ParsedProject { - inspection: IOSProjectInspection; - appTargets: IOSAppTarget[]; - appTargetCandidates: Array<{ - targetId: string; - targetName: string; - projectPath: string; - platform: IOSNativePlatform; - }>; - diagnostics: IOSDiagnostic[]; - sourceMemberships?: IOSTargetSourceMembership[]; -} - -export interface IOSTargetSourceMembership { - targetId: string; - targetName: string; - projectPath: string; - files: Array<{ absolutePath: string; relativePath: string }>; - complete: boolean; -} - const sourceMembershipByInspection = new WeakMap< IOSProjectInspectionResult, IOSTargetSourceMembership[] @@ -366,242 +341,6 @@ function stringArray(value: unknown): string[] { : []; } -function appleEntitlementState( - parsed: Record, -): IOSEntitlementsInspection["signInWithAppleState"] { - if (!Object.hasOwn(parsed, APPLE_SIGN_IN_KEY)) return "absent"; - const value = parsed[APPLE_SIGN_IN_KEY]; - return Array.isArray(value) && value.length === 1 && value[0] === "Default" ? "exact" : "invalid"; -} - -async function inspectEntitlements( - root: string, - absolutePath: string, - platform: IOSNativePlatform, - evidence: IOSSourceEvidence[], - diagnostics: IOSDiagnostic[], -): Promise { - const relativePath = relativeIOSPath(root, absolutePath); - const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); - if (file.status === "missing") { - diagnostics.push({ - code: "xcode.missing-entitlements", - severity: "warning", - message: `The configured entitlements file does not exist: ${relativePath}`, - remedy: "Create the file in Xcode or update CODE_SIGN_ENTITLEMENTS.", - evidence, - }); - return undefined; - } - - try { - if (file.status !== "ok") throw new Error("unreadable entitlements"); - const bytes = file.bytes; - if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { - throw new Error("binary plist"); - } - const parsed = parseIOSPlist(new TextDecoder().decode(bytes)); - if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); - - const associatedDomainsKey = "com.apple.developer.associated-domains"; - const rawAssociatedDomains = parsed[associatedDomainsKey]; - if ( - Object.hasOwn(parsed, associatedDomainsKey) && - (!Array.isArray(rawAssociatedDomains) || - !rawAssociatedDomains.every((value): value is string => typeof value === "string")) - ) { - diagnostics.push({ - code: "xcode.invalid-associated-domains", - severity: "warning", - message: `${relativePath} has an invalid Associated Domains entitlement value.`, - remedy: `Set ${associatedDomainsKey} to an array containing only strings, then rerun the inspector.`, - evidence: [{ path: relativePath, keyPath: associatedDomainsKey }], - }); - } - const associatedDomains = - Array.isArray(rawAssociatedDomains) && - rawAssociatedDomains.every((value): value is string => typeof value === "string") - ? rawAssociatedDomains - : []; - const applicationIdentifier = asString( - parsed[platform === "macos" ? "com.apple.application-identifier" : "application-identifier"], - ); - const signInWithAppleState = appleEntitlementState(parsed); - if (signInWithAppleState === "invalid") { - diagnostics.push({ - code: "xcode.invalid-apple-entitlement", - severity: "warning", - message: `${relativePath} has an invalid Sign in with Apple entitlement value.`, - remedy: `Set ${APPLE_SIGN_IN_KEY} to an array containing only Default, then rerun the inspector.`, - evidence: [{ path: relativePath, keyPath: APPLE_SIGN_IN_KEY }], - }); - } - return { - path: relativePath, - associatedDomains: associatedDomains.sort((left, right) => left.localeCompare(right)), - unresolvedAssociatedDomains: [], - applicationIdentifier, - teamIdentifier: asString(parsed["com.apple.developer.team-identifier"]), - signInWithAppleState, - signInWithApple: signInWithAppleState === "exact", - }; - } catch { - diagnostics.push({ - code: "xcode.unreadable-entitlements", - severity: "warning", - message: `Could not inspect entitlements at ${relativePath}. Only XML plist entitlements are read in portable mode.`, - remedy: "Open the file in Xcode and save it as XML, then rerun the inspector.", - evidence, - }); - return undefined; - } -} - -async function attachEntitlements( - root: string, - projectPath: string, - platform: IOSNativePlatform, - configurations: IOSBuildConfiguration[], - contextsByConfiguration: Map, - diagnostics: IOSDiagnostic[], -): Promise { - const cache = new Map(); - for (const configuration of configurations) { - if (configuration.entitlementsPath.state !== "resolved") continue; - const absolutePath = resolveEntitlementsAbsolutePath( - root, - projectPath, - configuration.entitlementsPath, - ); - if (!absolutePath) { - diagnostics.push({ - code: "xcode.external-path", - severity: "warning", - message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS outside the inspected root.`, - evidence: configuration.entitlementsPath.evidence, - }); - continue; - } - if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { - diagnostics.push({ - code: "xcode.external-path", - severity: "warning", - message: `${configuration.name} resolves CODE_SIGN_ENTITLEMENTS through a path outside the inspected root.`, - evidence: configuration.entitlementsPath.evidence, - }); - continue; - } - if (!cache.has(absolutePath)) { - cache.set( - absolutePath, - await inspectEntitlements( - root, - absolutePath, - platform, - configuration.entitlementsPath.evidence, - diagnostics, - ), - ); - } - const entitlements = cache.get(absolutePath); - if (!entitlements) continue; - - const contexts = contextsByConfiguration.get(configuration.name) ?? []; - const resolvedAssociatedDomains: string[] = []; - const unresolvedAssociatedDomains: string[] = []; - for (const domain of entitlements.associatedDomains) { - const expansions = contexts.map((context) => expandEntitlementDomain(domain, context)); - const resolved = expansions.filter((value): value is string => value != null); - if ( - contexts.length > 0 && - resolved.length === contexts.length && - new Set(resolved).size === 1 - ) { - resolvedAssociatedDomains.push(resolved[0]!); - } else { - unresolvedAssociatedDomains.push(domain); - } - } - if (unresolvedAssociatedDomains.length > 0) { - diagnostics.push({ - code: "xcode.unresolved-build-setting", - severity: "warning", - message: `${configuration.name} has associated-domain values with unresolved build settings.`, - remedy: - "Resolve the variables in the entitlements configuration before relying on domain checks.", - evidence: configuration.entitlementsPath.evidence, - }); - } - - const applicationIdentifier = entitlements.applicationIdentifier; - const prefixMatch = /^([A-Z0-9]{10})\.(.+)$/.exec(applicationIdentifier ?? ""); - const literalAppIdentifierPrefix = - prefixMatch && - configuration.bundleIdentifier.state === "resolved" && - bundleIdentifiersEqual(prefixMatch[2], configuration.bundleIdentifier.value) - ? prefixMatch[1] - : undefined; - configuration.entitlements = { - ...entitlements, - associatedDomains: resolvedAssociatedDomains.sort(), - unresolvedAssociatedDomains: unresolvedAssociatedDomains.sort(), - ...(literalAppIdentifierPrefix ? { literalAppIdentifierPrefix } : {}), - }; - } -} - -function expandEntitlementDomain( - raw: string, - context: EntitlementBuildContext, -): string | undefined { - const variable = /\$\(([^)]+)\)|\$\{([^}]+)\}/g; - const resolving = new Set(); - const expand = (value: string, depth: number): string | undefined => { - if (depth > 20) return undefined; - let unresolved = false; - variable.lastIndex = 0; - const expanded = value.replace(variable, (_match, parenthesized, braced) => { - const name = String(parenthesized ?? braced); - if (name.includes(":")) { - unresolved = true; - return ""; - } - const settingName = - context.settings[name] == null && name === "CFBundleIdentifier" - ? "PRODUCT_BUNDLE_IDENTIFIER" - : name; - if (resolving.has(settingName)) { - unresolved = true; - return ""; - } - const taints = [ - ...(context.settingTaints.get(settingName) ?? []), - ...(context.globalTaintOverrides.has(settingName) ? [] : context.globalTaints), - ]; - if (taints.length > 0) { - unresolved = true; - return ""; - } - const replacement = context.settings[settingName] ?? context.builtins[settingName]; - if (replacement == null) { - unresolved = true; - return ""; - } - resolving.add(settingName); - const nested = expand(replacement, depth + 1); - resolving.delete(settingName); - if (nested == null) unresolved = true; - return nested ?? ""; - }); - variable.lastIndex = 0; - return unresolved || variable.test(expanded) ? undefined : expanded; - }; - - const expanded = expand(raw, 0)?.trim(); - if (!expanded || /pk_(?:test|live)_/i.test(expanded)) return undefined; - return expanded; -} - function normalizeSynchronizedPath(path: string): string { return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); } @@ -929,19 +668,20 @@ async function sourceFilesForTarget(options: { }; } -async function parseProject( +async function parsePBXProject( root: string, projectPath: string, requestedTarget?: string, requestedPlatform?: IOSNativePlatform, -): Promise { +): Promise { const projectRelativePath = relativeIOSPath(root, projectPath); const pbxprojPath = resolve(projectPath, "project.pbxproj"); const pbxprojRelativePath = relativeIOSPath(root, pbxprojPath); const diagnostics: IOSDiagnostic[] = []; const emptyInspection = (objectVersion?: string): IOSProjectInspection => ({ path: projectRelativePath, - pbxprojPath: pbxprojRelativePath, + projectFilePath: pbxprojRelativePath, + projectFormat: "pbxproj", objectVersion, packages: [], appTargetIds: [], @@ -1041,7 +781,7 @@ async function parseProject( ); const packages = await inspectPackageReferences(root, projectPath, projectObject, objects); const appTargets: IOSAppTarget[] = []; - const appTargetCandidates: ParsedProject["appTargetCandidates"] = []; + const appTargetCandidates: ParsedIOSProject["appTargetCandidates"] = []; const targetIds = asStringArray(projectObject.targets).sort(); const sourceMemberships: IOSTargetSourceMembership[] = []; const sourceMembershipById = new Map< @@ -1277,7 +1017,8 @@ async function parseProject( return { inspection: { path: projectRelativePath, - pbxprojPath: pbxprojRelativePath, + projectFilePath: pbxprojRelativePath, + projectFormat: "pbxproj", objectVersion, packages, appTargetIds: appTargetCandidates.map((target) => target.targetId), @@ -1290,8 +1031,107 @@ async function parseProject( }; } +async function parseProject( + root: string, + projectPath: string, + requestedTarget?: string, + requestedPlatform?: IOSNativePlatform, +): Promise { + const projectRelativePath = relativeIOSPath(root, projectPath); + const resolution = await resolveXcodeProjectDocument(projectPath); + if (resolution.status === "found" && resolution.document.format === "pbxproj") { + return parsePBXProject(root, projectPath, requestedTarget, requestedPlatform); + } + + const documentPath = + resolution.status === "found" + ? resolution.document.absolutePath + : resolve(projectPath, "project.xcproj"); + const documentRelativePath = relativeIOSPath(root, documentPath); + const diagnostics: IOSDiagnostic[] = []; + const emptyInspection: IOSProjectInspection = { + path: projectRelativePath, + projectFilePath: documentRelativePath, + projectFormat: "xcproj", + packages: [], + appTargetIds: [], + diagnostics, + }; + if (resolution.status === "ambiguous") { + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + message: `${projectRelativePath} contains both project.pbxproj and project.xcproj.`, + remedy: "Keep exactly one Xcode project document in the project wrapper.", + evidence: [{ path: projectRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } + if (resolution.status === "missing") { + let hasUnsafeProjectDocument = false; + for (const candidate of ["project.pbxproj", "project.xcproj"]) { + try { + const info = await lstat(resolve(projectPath, candidate)); + hasUnsafeProjectDocument ||= !info.isSymbolicLink(); + } catch { + // Missing candidates are handled by the ordinary diagnostic below. + } + } + diagnostics.push({ + code: hasUnsafeProjectDocument ? "xcode.malformed-project" : "xcode.missing-project-file", + severity: "error", + message: hasUnsafeProjectDocument + ? `${projectRelativePath} contains project metadata that cannot be read safely.` + : `${projectRelativePath} does not contain project.pbxproj or project.xcproj.`, + evidence: [{ path: projectRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } + if (!(await pathIsSafelyWithinIOSRoot(root, documentPath))) { + diagnostics.push({ + code: "xcode.external-path", + severity: "error", + message: `${projectRelativePath} resolves its project document outside the inspected root.`, + evidence: [{ path: documentRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } + const file = await readBoundedRegularFile(documentPath, MAX_XCPROJ_BYTES); + if (file.status !== "ok") { + diagnostics.push({ + code: file.status === "missing" ? "xcode.missing-project-file" : "xcode.malformed-project", + severity: "error", + message: + file.status === "too-large" + ? `${documentRelativePath} is too large to inspect safely.` + : `Could not read ${documentRelativePath} safely.`, + evidence: [{ path: documentRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } + try { + const parsed = parseXCProjSource(file.bytes); + return await inspectXCProjProject({ + root, + projectPath, + documentPath, + document: parsed.root, + requestedTarget, + requestedPlatform, + }); + } catch { + diagnostics.push({ + code: "xcode.malformed-project", + severity: "error", + message: `Could not parse ${documentRelativePath} safely.`, + evidence: [{ path: documentRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } +} + function selectTarget( - candidates: ParsedProject["appTargetCandidates"], + candidates: ParsedIOSProject["appTargetCandidates"], requestedTarget: string | undefined, diagnostics: IOSDiagnostic[], ): IOSTargetSelection { @@ -1475,7 +1315,7 @@ export async function inspectIOSProject( const projects: IOSProjectInspection[] = []; const appTargets: IOSAppTarget[] = []; - const appTargetCandidates: ParsedProject["appTargetCandidates"] = []; + const appTargetCandidates: ParsedIOSProject["appTargetCandidates"] = []; const sourceMemberships: IOSTargetSourceMembership[] = []; for (const projectPath of [...projectPaths].sort()) { const parsed = await parseProject(root, projectPath, options.target, options.platform); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 7ceff9450..6cbd01142 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -26,7 +26,13 @@ import { } from "./install-sdk.ts"; import { applyIOSExistingFileTransaction } from "./file-transaction.ts"; import { type PbxObject, type PbxObjects } from "./pbx.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; +import { + createIOSFixture, + createIOSJSONFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import { parseXCProjSource, xcprojPackages, xcprojTargets } from "./xcproj.ts"; const temporaryDirectories: string[] = []; @@ -248,6 +254,67 @@ afterEach(async () => { }); describe("iOS Clerk SDK installer", () => { + test("installs Clerk products in project.xcproj and is byte-idempotent", async () => { + const root = await temporaryRoot("clerk-xcproj-install-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + const options = { + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + includeClerkKitUI: true, + }; + + const plan = await planIOSSDKInstall(options); + expect(plan).toMatchObject({ + status: "ready", + products: ["ClerkKit", "ClerkKitUI"], + supportedPlatforms: ["ios"], + }); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "applied" }); + + const installedBytes = await readFile(path); + const installed = parseXCProjSource(installedBytes); + expect(xcprojPackages(installed.root)).toEqual([ + expect.objectContaining({ + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + version: { "up-to-next-major-version": DEFAULT_CLERK_IOS_MINIMUM_VERSION }, + }), + ]); + expect(xcprojTargets(installed.root)[0]).toEqual( + expect.objectContaining({ + packageProductMembers: [ + expect.objectContaining({ package: "clerk-ios", "product-name": "ClerkKit" }), + expect.objectContaining({ package: "clerk-ios", "product-name": "ClerkKitUI" }), + ], + }), + ); + + const rerun = await planIOSSDKInstall(options); + expect(rerun.status).toBe("satisfied"); + expect(await applyIOSSDKInstall(rerun)).toMatchObject({ status: "satisfied" }); + expect(await readFile(path)).toEqual(installedBytes); + }); + + test("rejects a stale project.xcproj plan without overwriting newer bytes", async () => { + const root = await temporaryRoot("clerk-xcproj-stale-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + const options = { + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }; + const plan = await planIOSSDKInstall(options); + expect(plan.status).toBe("ready"); + await appendFile(path, "\n// user edit\n"); + const newerBytes = await readFile(path); + + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "stale" }); + expect(await readFile(path)).toEqual(newerBytes); + }); + test("blocks package planning when any configuration platform is unresolved", async () => { const root = await fixture({ platform: "macos", releasePlatform: "unresolved" }); await transformProject(root, removeClerkSDK); 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 b4d7e627c..dd7116690 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -6,6 +6,7 @@ import semver from "semver"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; import { applyIOSExistingFileTransaction, hashIOSFileBytes, @@ -22,6 +23,10 @@ import { type PbxObjects, } from "./pbx.ts"; import type { IOSNativePlatform } from "./types.ts"; +import { + prepareXCProjSDKInstall, + validateXCProjSDKInstallPostcondition, +} from "./xcproj-install-sdk.ts"; const APP_PRODUCT_TYPE = "com.apple.product-type.application"; const CLERK_REPOSITORY = "https://github.com/clerk/clerk-ios"; @@ -93,7 +98,7 @@ export interface IOSSDKInstallPlan { products: IOSSDKProduct[]; minimumVersion: string; requirePrebuiltAuthCompatibility?: true; - /** SHA-256 of the exact project.pbxproj bytes this plan was made from. */ + /** SHA-256 of the exact Xcode project document bytes this plan was made from. */ expectedPbxprojHash?: string; actions: string[]; blockers: IOSSDKInstallBlocker[]; @@ -829,121 +834,20 @@ function validateCandidateGraph( return true; } -async function prepareInstall(options: IOSSDKInstallOptions): Promise { - const root = resolve(options.root); - const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); - const minimumVersion = effectiveMinimumVersion(options); - if ( - !options.targetId || - !suppliedProjectPath || - isAbsolute(options.projectPath) || - !suppliedProjectPath.endsWith(".xcodeproj") || - !validMinimumVersion(minimumVersion) - ) { - return blocked( - options, - root, - suppliedProjectPath, - "invalid-selection", - "A selected root-relative .xcodeproj, target object ID, and valid minimum version are required.", - ); - } - - const absoluteProjectPath = resolve(root, suppliedProjectPath); - const projectPath = relativeIOSPath(root, absoluteProjectPath); - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); - if ( - !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || - !(await pathIsSafelyWithinIOSRoot(root, pbxprojPath)) - ) { - return blocked( - options, - root, - projectPath, - "external-path", - `${projectPath}/project.pbxproj resolves outside the project root.`, - { pbxprojPath }, - ); - } - - let info: Awaited>; - let originalBuffer: Buffer; - try { - info = await lstat(pbxprojPath); - if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) { - throw new Error("unsupported project file"); - } - originalBuffer = await readFile(pbxprojPath); - } catch { - return blocked( - options, - root, - projectPath, - "unreadable-project", - `${projectPath}/project.pbxproj is missing, too large, symlinked, or unreadable.`, - { pbxprojPath }, - ); - } - const originalBytes = new Uint8Array(originalBuffer); - const originalHash = hashIOSFileBytes(originalBytes); - const boundary = await prepareIOSFileMutationBoundary(root, pbxprojPath); - if (!boundary) { - return blocked( - options, - root, - projectPath, - "external-path", - `${projectPath}/project.pbxproj moved outside its prepared project boundary.`, - ); - } - const source = { - pbxprojPath, - boundary, - originalBytes, - originalHash, - mode: info.mode & 0o7777, - }; - - let originalText: string; - let parsed: ReturnType; - try { - originalText = new TextDecoder("utf-8", { fatal: true }).decode(originalBuffer); - parsed = parsePbxProject(originalText); - } catch { - return blocked( - options, - root, - projectPath, - "malformed-project", - `${projectPath}/project.pbxproj could not be parsed safely.`, - source, - ); - } - const parsedParts = projectParts(parsed, options.targetId); - if (!parsedParts) { - return blocked( - options, - root, - projectPath, - "target-not-found", - `The selected target ${options.targetId} does not exist in ${projectPath}.`, - source, - ); - } - if ( - parsedParts.targetObject.isa !== "PBXNativeTarget" || - asString(parsedParts.targetObject.productType) !== APP_PRODUCT_TYPE - ) { - return blocked( - options, - root, - projectPath, - "target-not-found", - `The selected object ${options.targetId} is not an application target.`, - source, - ); - } +interface VerifiedInstallTarget { + options: IOSSDKInstallOptions; + inspection: Awaited>; + generator: "xcodegen" | "tuist" | null; + supportedPlatforms: IOSNativePlatform[]; +} +async function verifyInstallTarget( + options: IOSSDKInstallOptions, + root: string, + projectPath: string, + absoluteProjectPath: string, + source: Omit, +): Promise { const inspection = await inspectIOSProject(root, { target: options.targetId, exhaustiveContainerDiscovery: true, @@ -985,6 +889,7 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise target.id === options.targetId && target.projectPath === projectPath, @@ -1030,6 +935,7 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise, +): Promise { + const verified = await verifyInstallTarget( + requestedOptions, + root, + projectPath, + absoluteProjectPath, + source, + ); + if ("plan" in verified) return verified; + const { options, inspection, generator, supportedPlatforms } = verified; + + const products = requestedProducts(options.includeClerkKitUI); + const resolved = await resolvedClerkVersions(root, projectPath, inspection); + const prepared = await prepareXCProjSDKInstall({ + root, + projectPath: absoluteProjectPath, + source: source.originalBytes!, + targetId: options.targetId, + products, + supportedPlatforms, + minimumVersion: effectiveMinimumVersion(options), + requiredCompatibilityVersion: requiredCompatibilityVersion( + options.requirePrebuiltAuthCompatibility === true, + ), + requirePrebuiltAuthCompatibility: options.requirePrebuiltAuthCompatibility === true, + resolvedClerkVersions: resolved, + }); + if (prepared.status === "blocked") { + return blocked( + options, + root, + projectPath, + prepared.blocker.code, + prepared.blocker.message, + source, + ); + } + if (prepared.status === "satisfied") { + return { + ...source, + plan: makePlan(options, root, projectPath, "satisfied", { + expectedPbxprojHash: source.originalHash, + }), + }; + } + if (generator) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${ + generator === "xcodegen" ? "XcodeGen" : "Tuist" + } project; update its source manifest instead of generated project output.`, + source, + ); + } + return { + ...source, + candidateBytes: prepared.candidateBytes, + candidateHash: hashIOSFileBytes(prepared.candidateBytes), + plan: makePlan(options, root, projectPath, "ready", { + actions: prepared.actions, + expectedPbxprojHash: source.originalHash, + }), + }; +} + +async function prepareInstall(options: IOSSDKInstallOptions): Promise { + const root = resolve(options.root); + const suppliedProjectPath = options.projectPath.replaceAll("\\", "/"); + const minimumVersion = effectiveMinimumVersion(options); + if ( + !options.targetId || + !suppliedProjectPath || + isAbsolute(options.projectPath) || + !suppliedProjectPath.endsWith(".xcodeproj") || + !validMinimumVersion(minimumVersion) + ) { + return blocked( + options, + root, + suppliedProjectPath, + "invalid-selection", + "A selected root-relative .xcodeproj, target object ID, and valid minimum version are required.", + ); + } + + const absoluteProjectPath = resolve(root, suppliedProjectPath); + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const documentResolution = await resolveXcodeProjectDocument(absoluteProjectPath); + const projectDocumentPath = + documentResolution.status === "found" + ? documentResolution.document.absolutePath + : resolve(absoluteProjectPath, "project.pbxproj"); + if ( + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) || + !(await pathIsSafelyWithinIOSRoot(root, projectDocumentPath)) + ) { + return blocked( + options, + root, + projectPath, + "external-path", + `${projectPath}/${projectDocumentPath.split("/").at(-1)} resolves outside the project root.`, + { pbxprojPath: projectDocumentPath }, + ); + } + + if (documentResolution.status !== "found") { + return blocked( + options, + root, + projectPath, + documentResolution.status === "ambiguous" ? "malformed-project" : "unreadable-project", + documentResolution.status === "ambiguous" + ? `${projectPath} contains both project.pbxproj and project.xcproj, so its project format is ambiguous.` + : `${projectPath} has no readable Xcode project document.`, + { pbxprojPath: projectDocumentPath }, + ); + } + + let info: Awaited>; + let originalBuffer: Buffer; + try { + info = await lstat(projectDocumentPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PBXPROJ_BYTES) { + throw new Error("unsupported project file"); + } + originalBuffer = await readFile(projectDocumentPath); + } catch { + return blocked( + options, + root, + projectPath, + "unreadable-project", + `${projectPath}/${documentResolution.document.fileName} is missing, too large, symlinked, or unreadable.`, + { pbxprojPath: projectDocumentPath }, + ); + } + const originalBytes = new Uint8Array(originalBuffer); + const originalHash = hashIOSFileBytes(originalBytes); + const boundary = await prepareIOSFileMutationBoundary(root, projectDocumentPath); + if (!boundary) { + return blocked( + options, + root, + projectPath, + "external-path", + `${projectPath}/${documentResolution.document.fileName} moved outside its prepared project boundary.`, + ); + } + const source = { + pbxprojPath: projectDocumentPath, + boundary, + originalBytes, + originalHash, + mode: info.mode & 0o7777, + }; + + if (documentResolution.document.format === "xcproj") { + try { + return await prepareXCProjInstallDocument( + options, + root, + projectPath, + absoluteProjectPath, + source, + ); + } catch { + return blocked( + options, + root, + projectPath, + "malformed-project", + `${projectPath}/project.xcproj could not be parsed safely.`, + source, + ); + } + } + + let originalText: string; + let parsed: ReturnType; + try { + originalText = new TextDecoder("utf-8", { fatal: true }).decode(originalBuffer); + parsed = parsePbxProject(originalText); + } catch { + return blocked( + options, + root, + projectPath, + "malformed-project", + `${projectPath}/project.pbxproj could not be parsed safely.`, + source, + ); + } + const parsedParts = projectParts(parsed, options.targetId); + if (!parsedParts) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected target ${options.targetId} does not exist in ${projectPath}.`, + source, + ); + } + if ( + parsedParts.targetObject.isa !== "PBXNativeTarget" || + asString(parsedParts.targetObject.productType) !== APP_PRODUCT_TYPE + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + `The selected object ${options.targetId} is not an application target.`, + source, + ); + } + + const verified = await verifyInstallTarget( + options, + root, + projectPath, + absoluteProjectPath, + source, + ); + if ("plan" in verified) return verified; + ({ options } = verified); + const { inspection, generator, supportedPlatforms } = verified; + // Parse a second model instead of structured-cloning. pbxproj data literals // can be Buffers, which structuredClone turns into writer-incompatible // Uint8Arrays under Bun. @@ -1491,13 +1638,94 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise { const absoluteProjectPath = resolve(plan.root, plan.projectPath); - const pbxprojPath = resolve(absoluteProjectPath, "project.pbxproj"); + const documentResolution = await resolveXcodeProjectDocument(absoluteProjectPath); + if (documentResolution.status !== "found") return false; + const pbxprojPath = documentResolution.document.absolutePath; if (!(await pathIsSafelyWithinIOSRoot(plan.root, pbxprojPath))) return false; + + if (documentResolution.document.format === "xcproj") { + let bytes: Uint8Array; + try { + bytes = new Uint8Array(await readFile(pbxprojPath)); + } catch { + return false; + } + if ( + !validateXCProjSDKInstallPostcondition(bytes, { + targetId: plan.targetId, + products: plan.products, + supportedPlatforms: plan.supportedPlatforms, + }) + ) { + return false; + } + const inspection = await inspectIOSProject(plan.root, { + target: plan.targetId, + exhaustiveContainerDiscovery: true, + platform: plan.platform, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) return false; + if ( + inspection.generatedProject || + (await generatedProjectKind(plan.root, absoluteProjectPath)) + ) { + return false; + } + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== plan.targetId || + inspection.selection.projectPath !== plan.projectPath || + inspection.selection.platform !== plan.platform + ) { + return false; + } + const resolved = await resolvedClerkVersions(plan.root, plan.projectPath, inspection); + const prepared = await prepareXCProjSDKInstall({ + root: plan.root, + projectPath: absoluteProjectPath, + source: bytes, + targetId: plan.targetId, + products: plan.products, + supportedPlatforms: plan.supportedPlatforms, + minimumVersion: plan.minimumVersion, + requiredCompatibilityVersion: requiredCompatibilityVersion( + plan.requirePrebuiltAuthCompatibility === true, + ), + requirePrebuiltAuthCompatibility: plan.requirePrebuiltAuthCompatibility === true, + resolvedClerkVersions: resolved, + }); + if (prepared.status !== "satisfied") return false; + for (const platform of plan.supportedPlatforms) { + const platformInspection = + platform === plan.platform + ? inspection + : await inspectIOSProject(plan.root, { + target: plan.targetId, + exhaustiveContainerDiscovery: true, + platform, + }); + if ( + hasIncompleteIOSContainerDiscovery(platformInspection) || + platformInspection.selection.state !== "selected" || + platformInspection.selection.targetId !== plan.targetId || + platformInspection.selection.projectPath !== plan.projectPath || + platformInspection.selection.platform !== platform + ) { + return false; + } + const platformTarget = platformInspection.appTargets.find( + (item) => item.id === plan.targetId && item.projectPath === plan.projectPath, + ); + if (!platformTarget?.platformEvidenceComplete) return false; + } + return true; + } + let parsed: ReturnType; try { parsed = parsePbxProject(await readFile(pbxprojPath, "utf8")); @@ -1597,7 +1825,7 @@ export async function planIOSSDKInstall(options: IOSSDKInstallOptions): Promise< /** * An internal SDK preparation result for a larger iOS file transaction. The - * ready case contains candidate PBX bytes and must not be logged or serialized. + * ready case contains candidate Xcode project bytes and must not be logged or serialized. * * @internal */ @@ -1612,7 +1840,7 @@ export type PreparedIOSSDKInstallMutation = }; /** - * Reprepares a serialized SDK plan and exposes its PBX mutation without writing + * Reprepares a serialized SDK plan and exposes its Xcode project mutation without writing * it so a caller can combine it with Swift source mutations. * * @internal The ready result contains candidate bytes. diff --git a/packages/cli-core/src/commands/init/ios/macos-network.ts b/packages/cli-core/src/commands/init/ios/macos-network.ts index 85f952033..f58dcdfa3 100644 --- a/packages/cli-core/src/commands/init/ios/macos-network.ts +++ b/packages/cli-core/src/commands/init/ios/macos-network.ts @@ -21,6 +21,7 @@ import { import { inspectIOSProject } from "./inspect.ts"; import { isRecord } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; +import { xcodeProjectDocumentPath } from "./project-document.ts"; import type { IOSAppTarget, IOSValueResolution } from "./types.ts"; const APP_SANDBOX_KEY = "com.apple.security.app-sandbox"; @@ -822,7 +823,10 @@ export async function prepareMacOSNetworkCapabilityMutation( ); } const entitlementsPath = resolve(plan.root, createFile.path); - const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const pbxprojPath = await xcodeProjectDocumentPath(resolve(plan.root, plan.projectPath)); + if (!pbxprojPath) { + return blockPrepared(plan, "invalid-plan", "The selected Xcode project document is missing."); + } const baseEntitlements = baseByPath.get(entitlementsPath); const basePbx = baseByPath.get(pbxprojPath); if (baseEntitlements && !isCreateMutation(baseEntitlements)) return { status: "stale", plan }; diff --git a/packages/cli-core/src/commands/init/ios/project-adapter.ts b/packages/cli-core/src/commands/init/ios/project-adapter.ts new file mode 100644 index 000000000..ac1a34b74 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/project-adapter.ts @@ -0,0 +1,28 @@ +import type { + IOSAppTarget, + IOSDiagnostic, + IOSNativePlatform, + IOSProjectInspection, +} from "./types.ts"; + +/** Internal result shared by the PBX and JSON Xcode-project readers. */ +export interface ParsedIOSProject { + inspection: IOSProjectInspection; + appTargets: IOSAppTarget[]; + appTargetCandidates: Array<{ + targetId: string; + targetName: string; + projectPath: string; + platform: IOSNativePlatform; + }>; + diagnostics: IOSDiagnostic[]; + sourceMemberships?: IOSTargetSourceMembership[]; +} + +export interface IOSTargetSourceMembership { + targetId: string; + targetName: string; + projectPath: string; + files: Array<{ absolutePath: string; relativePath: string }>; + complete: boolean; +} diff --git a/packages/cli-core/src/commands/init/ios/project-document.test.ts b/packages/cli-core/src/commands/init/ios/project-document.test.ts new file mode 100644 index 000000000..a0cc5ea5d --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/project-document.test.ts @@ -0,0 +1,68 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { resolveXcodeProjectDocument } from "./project-document.ts"; + +describe("resolveXcodeProjectDocument", () => { + const roots: string[] = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); + }); + + async function project(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-xcode-document-")); + roots.push(root); + const path = join(root, "App.xcodeproj"); + await mkdir(path); + return path; + } + + test("resolves a legacy PBX project document", async () => { + const path = await project(); + await writeFile(join(path, "project.pbxproj"), "// !$*UTF8*$!\n"); + + expect(await resolveXcodeProjectDocument(path)).toEqual({ + status: "found", + document: { + format: "pbxproj", + fileName: "project.pbxproj", + projectPath: path, + absolutePath: join(path, "project.pbxproj"), + }, + }); + }); + + test("resolves an Xcode JSON project document", async () => { + const path = await project(); + await writeFile(join(path, "project.xcproj"), '{ "files": [], }\n'); + + expect(await resolveXcodeProjectDocument(path)).toEqual({ + status: "found", + document: { + format: "xcproj", + fileName: "project.xcproj", + projectPath: path, + absolutePath: join(path, "project.xcproj"), + }, + }); + }); + + test("fails closed when both formats exist", async () => { + const path = await project(); + await writeFile(join(path, "project.pbxproj"), "// !$*UTF8*$!\n"); + await writeFile(join(path, "project.xcproj"), "{}\n"); + + expect(await resolveXcodeProjectDocument(path)).toEqual({ status: "ambiguous" }); + }); + + test("does not follow a project-document symlink", async () => { + const path = await project(); + const outside = join(path, "..", "outside.xcproj"); + await writeFile(outside, "{}\n"); + await symlink(outside, join(path, "project.xcproj")); + + expect(await resolveXcodeProjectDocument(path)).toEqual({ status: "missing" }); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/project-document.ts b/packages/cli-core/src/commands/init/ios/project-document.ts new file mode 100644 index 000000000..22354cf04 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/project-document.ts @@ -0,0 +1,72 @@ +import { lstat } from "node:fs/promises"; +import { resolve } from "node:path"; + +export type XcodeProjectDocumentFormat = "pbxproj" | "xcproj"; + +export interface XcodeProjectDocumentRef { + format: XcodeProjectDocumentFormat; + /** Absolute path to the .xcodeproj wrapper. */ + projectPath: string; + /** Absolute path to project.pbxproj or project.xcproj. */ + absolutePath: string; + fileName: "project.pbxproj" | "project.xcproj"; +} + +export type XcodeProjectDocumentResolution = + | { status: "found"; document: XcodeProjectDocumentRef } + | { status: "missing" } + | { status: "ambiguous" }; + +async function isRegularProjectDocument(path: string): Promise { + try { + const info = await lstat(path); + return info.isFile() && !info.isSymbolicLink(); + } catch { + return false; + } +} + +/** + * Resolves the on-disk document inside an Xcode project wrapper. + * + * Xcode 27 supports the legacy OpenStep `project.pbxproj` representation and + * the hierarchical JSON `project.xcproj` representation. A valid wrapper has + * exactly one. Treating a wrapper containing both as ambiguous keeps every + * ownership-sensitive reader and writer fail closed. + */ +export async function resolveXcodeProjectDocument( + projectPath: string, +): Promise { + const absoluteProjectPath = resolve(projectPath); + const candidates = [ + { + format: "pbxproj" as const, + fileName: "project.pbxproj" as const, + absolutePath: resolve(absoluteProjectPath, "project.pbxproj"), + }, + { + format: "xcproj" as const, + fileName: "project.xcproj" as const, + absolutePath: resolve(absoluteProjectPath, "project.xcproj"), + }, + ]; + const existing = []; + for (const candidate of candidates) { + if (await isRegularProjectDocument(candidate.absolutePath)) existing.push(candidate); + } + if (existing.length === 0) return { status: "missing" }; + if (existing.length !== 1) return { status: "ambiguous" }; + const candidate = existing[0]!; + return { + status: "found", + document: { + ...candidate, + projectPath: absoluteProjectPath, + }, + }; +} + +export async function xcodeProjectDocumentPath(projectPath: string): Promise { + const resolution = await resolveXcodeProjectDocument(projectPath); + return resolution.status === "found" ? resolution.document.absolutePath : undefined; +} diff --git a/packages/cli-core/src/commands/init/ios/test-helpers.ts b/packages/cli-core/src/commands/init/ios/test-helpers.ts index e5b995191..e11fd2443 100644 --- a/packages/cli-core/src/commands/init/ios/test-helpers.ts +++ b/packages/cli-core/src/commands/init/ios/test-helpers.ts @@ -1,5 +1,5 @@ -import { lstat, mkdir, readdir, readFile, readlink, rm, writeFile } from "node:fs/promises"; -import { join, relative } from "node:path"; +import { cp, lstat, mkdir, readdir, readFile, readlink, rm, writeFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import type { PbxObjects } from "./pbx.ts"; @@ -41,6 +41,8 @@ const IDS = { secondFrameworksPhase: "444444444444444444444444", } as const; +const IOS_JSON_FIXTURE = resolve(import.meta.dir, "../../../../../../test/e2e/fixtures/ios-json"); + export interface IOSFixtureOptions { complete?: boolean; platform?: "ios" | "macos"; @@ -331,6 +333,11 @@ export async function createIOSFixture( await Bun.write(join(root, "Project.swift"), "import ProjectDescription\n"); } +/** Copies the canonical Xcode JSON project fixture into a temporary test root. */ +export async function createIOSJSONFixture(root: string): Promise { + await cp(IOS_JSON_FIXTURE, root, { recursive: true }); +} + /** Converts the classic fixture into the modern synchronized-root shape used by new Xcode apps. */ export async function convertIOSFixtureToSynchronizedRoot(root: string): Promise { const synchronizedRootId = "515151515151515151515151"; diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index d6ea6f41e..26d13578d 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -167,7 +167,9 @@ export interface IOSAppTarget { export interface IOSProjectInspection { path: string; - pbxprojPath: string; + /** Project document selected inside the .xcodeproj wrapper. */ + projectFilePath: string; + projectFormat: "pbxproj" | "xcproj"; objectVersion?: string; packages: IOSPackageReference[]; appTargetIds: string[]; diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts new file mode 100644 index 000000000..25147bfff --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -0,0 +1,270 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { IOSDiagnostic } from "./types.ts"; +import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; +import { xcprojTargets, type XCProjRecord } from "./xcproj.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +async function inspectFixture( + projectOverrides: XCProjRecord = {}, + targetOverrides: XCProjRecord = {}, +) { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-build-settings-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const projectDocumentPath = join(projectPath, "project.xcproj"); + await mkdir(projectPath, { recursive: true }); + + const target: XCProjRecord = { + name: "Example", + id: "TARGET-ID", + "product-type": "application", + "build-settings": { + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited).Example", + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: ["iphoneos", "iphonesimulator"], + }, + ...targetOverrides, + }; + const project: XCProjRecord = { + configurations: ["Debug", "Release"], + "build-settings": { + SDKROOT: "iphoneos", + PRODUCT_BUNDLE_IDENTIFIER: "com.example", + }, + targets: [target], + ...projectOverrides, + }; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath, + project, + target: xcprojTargets(project)[0]!, + diagnostics, + }); + return { configurations, diagnostics, projectDocumentPath, root }; +} + +describe("inspectXCProjTargetBuildConfigurations", () => { + test("evaluates every project configuration and target inherited values", async () => { + const { configurations, diagnostics } = await inspectFixture(); + + expect(configurations.map(({ model }) => model.name)).toEqual(["Debug", "Release"]); + for (const configuration of configurations) { + expect(configuration.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Example", + }); + expect(configuration.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "ABCDE12345", + }); + expect(configuration.platformEvidenceComplete).toBe(true); + expect(configuration.supportedPlatforms).toEqual(["ios"]); + } + expect(diagnostics).toEqual([]); + }); + + test("applies configuration-conditional target overrides", async () => { + const { configurations } = await inspectFixture( + {}, + { + "build-settings": { + PRODUCT_BUNDLE_IDENTIFIER: "$(inherited).Example", + DEVELOPMENT_TEAM: "BASETEAM123", + "DEVELOPMENT_TEAM[config=Debug]": "DEBUG12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + ); + + expect(configurations[0]?.model).toMatchObject({ + name: "Debug", + developmentTeam: { state: "resolved", value: "DEBUG12345" }, + }); + expect(configurations[1]?.model).toMatchObject({ + name: "Release", + developmentTeam: { state: "resolved", value: "BASETEAM123" }, + }); + }); + + test("preserves SDK-conditional conflicts from the shared evaluator", async () => { + const { configurations, diagnostics } = await inspectFixture( + {}, + { + "build-settings": { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.Example", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphoneos*]": "com.example.Device", + "PRODUCT_BUNDLE_IDENTIFIER[sdk=iphonesimulator*]": "com.example.Simulator", + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "unresolved", + missingVariables: ["sdk/architecture-conditioned build setting"], + }); + expect(diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.conflicting-build-setting", + evidence: expect.arrayContaining([ + expect.objectContaining({ path: "Example.xcodeproj/project.xcproj" }), + ]), + }), + ); + }); + + test("layers path-based project and target xcconfig files", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-xcconfig-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const projectDocumentPath = join(projectPath, "project.xcproj"); + await mkdir(join(root, "Config"), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await Bun.write( + join(root, "Config", "Project.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example\nDEVELOPMENT_TEAM = PROJECT1234", + ); + await Bun.write( + join(root, "Config", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = $(inherited).XCProj\nDEVELOPMENT_TEAM = $(inherited)", + ); + const rawTarget: XCProjRecord = { + name: "Example", + id: "TARGET-ID", + "product-type": "application", + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "Config", "relative-path": "Target.xcconfig" }, + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }; + const project: XCProjRecord = { + configurations: [ + { + name: "Debug", + file: { anchor: "Config", "relative-path": "Project.xcconfig" }, + }, + ], + "build-settings": { SDKROOT: "iphoneos" }, + targets: [rawTarget], + }; + const diagnostics: IOSDiagnostic[] = []; + + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath, + project, + target: xcprojTargets(project)[0]!, + diagnostics, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.XCProj", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "PROJECT1234", + }); + expect(diagnostics).toEqual([]); + }); + + test("resolves Xcode 27 string-form xcconfig references through the file graph", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-string-xcconfig-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const projectDocumentPath = join(projectPath, "project.xcproj"); + await mkdir(join(root, "Config"), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await Bun.write( + join(root, "Config", "Project.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example\nDEVELOPMENT_TEAM = PROJECT1234", + ); + await Bun.write( + join(root, "Config", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = $(inherited).XCProj\nDEVELOPMENT_TEAM = $(inherited)", + ); + const rawTarget: XCProjRecord = { + name: "Example", + id: "TARGET-ID", + "product-type": "application", + "specialized-configurations": [ + { name: "Debug", file: "Target.xcconfig" }, + { name: "Release", file: "Target.xcconfig" }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }; + const project: XCProjRecord = { + configurations: [ + { name: "Debug", file: "Project.xcconfig" }, + { name: "Release", file: "Project.xcconfig" }, + ], + files: [{ path: "Config/Project.xcconfig" }, { path: "Config/Target.xcconfig" }], + "build-settings": { SDKROOT: "iphoneos" }, + targets: [rawTarget], + }; + const diagnostics: IOSDiagnostic[] = []; + + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath, + project, + target: xcprojTargets(project)[0]!, + diagnostics, + }); + + expect(configurations).toHaveLength(2); + for (const configuration of configurations) { + expect(configuration.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.XCProj", + }); + expect(configuration.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "PROJECT1234", + }); + } + expect(diagnostics).toEqual([]); + }); + + test("fails string-form xcconfig resolution closed when a filename is ambiguous", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + files: [{ path: "ConfigA/Target.xcconfig" }, { path: "ConfigB/Target.xcconfig" }], + }, + { + "specialized-configurations": [{ name: "Debug", file: "Target.xcconfig" }], + }, + ); + + expect(configurations[0]?.model.bundleIdentifier.state).toBe("unresolved"); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.dangling-reference", severity: "error" }), + ); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts new file mode 100644 index 000000000..0d01b6337 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -0,0 +1,306 @@ +import { basename, dirname, resolve } from "node:path"; +import { + inspectTargetBuildConfigurations, + type InspectedTargetConfiguration, +} from "./build-settings.ts"; +import type { PbxObject, PbxObjects } from "./pbx.ts"; +import type { IOSDiagnostic, IOSNativePlatform } from "./types.ts"; +import { + XCProjError, + xcprojArray, + xcprojRecord, + xcprojString, + type XCProjRecord, + type XCProjTarget, +} from "./xcproj.ts"; + +interface XCProjConfiguration { + name: string; + file?: string | XCProjRecord; +} + +export interface InspectXCProjTargetBuildConfigurationsOptions { + /** Root directory being inspected by Clerk. */ + root: string; + /** The containing .xcodeproj directory. */ + projectPath: string; + /** The resolved project.xcproj document. */ + projectDocumentPath: string; + /** Parsed and schema-validated project.xcproj root object. */ + project: XCProjRecord; + /** Normalized target returned by xcprojTargets(). */ + target: XCProjTarget; + diagnostics: IOSDiagnostic[]; + platform?: IOSNativePlatform; +} + +function invalidSchema(): never { + throw new XCProjError( + "invalid-schema", + "project.xcproj contains an unsupported build-configuration shape.", + ); +} + +function buildSettings(value: unknown): Record { + if (value === undefined) return {}; + const record = xcprojRecord(value); + const result: Record = {}; + for (const [key, setting] of Object.entries(record)) { + if (typeof setting === "string") { + result[key] = setting; + continue; + } + if (!Array.isArray(setting) || !setting.every((item) => typeof item === "string")) { + invalidSchema(); + } + result[key] = setting; + } + return result; +} + +function pbxBuildSettings( + settings: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(settings).map(([key, value]) => [ + key, + Array.isArray(value) ? [...value] : value, + ]), + ); +} + +function configuration(value: unknown): XCProjConfiguration { + if (typeof value === "string") { + if (value === "") invalidSchema(); + return { name: value }; + } + const record = xcprojRecord(value); + const name = xcprojString(record.name); + if (name === "") invalidSchema(); + if (record.file === "") invalidSchema(); + return { + name, + file: + record.file === undefined + ? undefined + : typeof record.file === "string" + ? record.file + : xcprojRecord(record.file), + }; +} + +function configurations(value: unknown, requireAtLeastOne = true): XCProjConfiguration[] { + const parsed = xcprojArray(value ?? []).map(configuration); + const names = new Set(); + for (const item of parsed) { + if (names.has(item.name)) invalidSchema(); + names.add(item.name); + } + if (requireAtLeastOne && parsed.length === 0) invalidSchema(); + return parsed; +} + +function simpleNamePath(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!Array.isArray(value)) return undefined; + const components: string[] = []; + for (const component of value) { + if (typeof component === "string") { + components.push(component); + continue; + } + if ( + typeof component === "object" && + component !== null && + !Array.isArray(component) && + typeof (component as XCProjRecord).name === "string" + ) { + components.push((component as XCProjRecord).name as string); + continue; + } + return undefined; + } + return components.join("/"); +} + +/** + * Resolve the compact, path-based form emitted for ordinary checked-in + * xcconfig files. Object-ID anchors require the full groups-and-files graph; + * leave those unresolved so the shared evaluator fails closed. + */ +function projectReferencePath( + projectDirectory: string, + parent: string, + path: string, +): string | undefined { + if (!path || /^<(?:PRODUCTS|SDK|DEVELOPER)>\//.test(path)) return undefined; + if (path.startsWith("/")) { + return resolve(projectDirectory, path.slice("/".length)); + } + if (path.startsWith("<")) return undefined; + return resolve(parent, path); +} + +function configurationFileIndex(projectPath: string, project: XCProjRecord): Map { + const projectDirectory = dirname(projectPath); + const paths = new Map>(); + const add = (token: string, path: string): void => { + if (!token) return; + const matches = paths.get(token) ?? new Set(); + matches.add(path); + paths.set(token, matches); + }; + const visit = (raw: unknown, parent: string): void => { + const reference = xcprojRecord(raw); + const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const path = typeof reference.path === "string" ? reference.path : ""; + if (kind === "group") { + const groupDirectory = path ? projectReferencePath(projectDirectory, parent, path) : parent; + if (!groupDirectory) return; + for (const child of xcprojArray(reference.children ?? [])) visit(child, groupDirectory); + return; + } + if (kind !== "file" || !path) return; + const absolutePath = projectReferencePath(projectDirectory, parent, path); + if (!absolutePath) return; + const displayName = typeof reference.name === "string" ? reference.name : basename(path); + add(displayName, absolutePath); + add(path.replaceAll("\\", "/"), absolutePath); + }; + for (const reference of xcprojArray(project.files ?? [])) visit(reference, projectDirectory); + return new Map([...paths].map(([token, matches]) => [token, [...matches].sort()] as const)); +} + +function configurationFilePath( + projectPath: string, + file: string | XCProjRecord | undefined, + indexedFiles: ReadonlyMap, +): string | undefined { + if (!file) return undefined; + if (typeof file === "string") { + const matches = indexedFiles.get(file.replaceAll("\\", "/")) ?? []; + return matches.length === 1 ? matches[0] : undefined; + } + const anchor = simpleNamePath(file.anchor); + const relativePath = simpleNamePath(file["relative-path"]); + if (!anchor || anchor.startsWith("id:") || relativePath === undefined) return undefined; + return resolve(dirname(projectPath), anchor, relativePath); +} + +function attachBaseConfiguration( + objects: PbxObjects, + configurationObject: PbxObject, + projectPath: string, + file: string | XCProjRecord | undefined, + indexedFiles: ReadonlyMap, + referenceId: string, +): void { + if (!file) return; + configurationObject.baseConfigurationReference = referenceId; + const path = configurationFilePath(projectPath, file, indexedFiles); + if (!path) return; + objects[referenceId] = { + isa: "PBXFileReference", + path, + sourceTree: "", + }; +} + +/** + * Adapts Xcode's JSON project build-setting representation to the existing + * evaluator. The compatibility objects are read-only and are never serialized + * back to either project format. + */ +export async function inspectXCProjTargetBuildConfigurations( + options: InspectXCProjTargetBuildConfigurationsOptions, +): Promise { + const { project, target } = options; + const projectConfigurations = configurations(project.configurations); + const targetSpecializations = configurations( + target.raw["specialized-configurations"] ?? [], + false, + ); + const specializationByName = new Map(targetSpecializations.map((item) => [item.name, item])); + for (const specialized of targetSpecializations) { + if (!projectConfigurations.some(({ name }) => name === specialized.name)) invalidSchema(); + } + + const objects: PbxObjects = {}; + const projectConfigurationIds: string[] = []; + const targetConfigurationIds: string[] = []; + const projectSettings = buildSettings(project["build-settings"]); + const indexedFiles = configurationFileIndex(options.projectPath, project); + + for (const [index, projectConfiguration] of projectConfigurations.entries()) { + const projectConfigurationId = `__xcproj_project_configuration_${index}`; + const targetConfigurationId = `__xcproj_target_configuration_${index}`; + const projectBaseReferenceId = `__xcproj_project_xcconfig_${index}`; + const targetBaseReferenceId = `__xcproj_target_xcconfig_${index}`; + const targetSpecialization = specializationByName.get(projectConfiguration.name); + + const projectConfigurationObject: PbxObject = { + isa: "XCBuildConfiguration", + name: projectConfiguration.name, + buildSettings: pbxBuildSettings(projectSettings), + }; + attachBaseConfiguration( + objects, + projectConfigurationObject, + options.projectPath, + projectConfiguration.file, + indexedFiles, + projectBaseReferenceId, + ); + objects[projectConfigurationId] = projectConfigurationObject; + projectConfigurationIds.push(projectConfigurationId); + + const targetConfigurationObject: PbxObject = { + isa: "XCBuildConfiguration", + name: projectConfiguration.name, + buildSettings: pbxBuildSettings(target.buildSettings), + }; + attachBaseConfiguration( + objects, + targetConfigurationObject, + options.projectPath, + targetSpecialization?.file, + indexedFiles, + targetBaseReferenceId, + ); + objects[targetConfigurationId] = targetConfigurationObject; + targetConfigurationIds.push(targetConfigurationId); + } + + const projectListId = "__xcproj_project_configuration_list"; + const targetListId = "__xcproj_target_configuration_list"; + objects[projectListId] = { + isa: "XCConfigurationList", + buildConfigurations: projectConfigurationIds, + }; + objects[targetListId] = { + isa: "XCConfigurationList", + buildConfigurations: targetConfigurationIds, + }; + + return inspectTargetBuildConfigurations({ + root: options.root, + projectPath: options.projectPath, + projectDocumentPath: options.projectDocumentPath, + groupRootDirectory: dirname(options.projectPath), + projectObject: { + isa: "PBXProject", + buildConfigurationList: projectListId, + }, + targetId: target.id, + targetObject: { + isa: "PBXNativeTarget", + name: target.name, + productName: target.name, + buildConfigurationList: targetListId, + }, + objects, + parents: new Map(), + diagnostics: options.diagnostics, + platform: options.platform, + }); +} diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts new file mode 100644 index 000000000..2d92ba16f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -0,0 +1,675 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, extname, relative, resolve, sep } from "node:path"; +import { addBuildSettingConflictDiagnostics } from "./build-settings.ts"; +import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; +import { attachEntitlements } from "./entitlements-inspection.ts"; +import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; +import type { IOSTargetSourceMembership, ParsedIOSProject } from "./project-adapter.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { isClerkIOSRepository, sanitizeRepositoryURL } from "./pbx.ts"; +import { inspectSwiftSources } from "./swift.ts"; +import type { + IOSAppTarget, + IOSClerkPackageState, + IOSDiagnostic, + IOSNativePlatform, + IOSPackageReference, + IOSProductLinkState, + IOSSwiftInspection, +} from "./types.ts"; +import { + XCProjError, + type XCProjRecord, + type XCProjTarget, + xcprojArray, + xcprojPackages, + xcprojRecord, + xcprojString, + xcprojStringArray, + xcprojTargets, +} from "./xcproj.ts"; + +const APP_PRODUCT_TYPES = new Set(["application", "com.apple.product-type.application"]); +const MAX_SOURCE_FILES = 2_500; +const MAX_SOURCE_DEPTH = 24; +const SOURCE_IGNORES = new Set([ + ".build", + ".git", + ".swiftpm", + "build", + "Carthage", + "DerivedData", + "Pods", + "SourcePackages", +]); + +function emptySwiftInspection(): IOSSwiftInspection { + return { + sourceFilesScanned: 0, + evidenceComplete: false, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + appRootEvidence: [], + environmentInjections: [], + rootEnvironmentInjections: [], + environmentConsumers: [], + authViewReferences: [], + authFlowReferences: [], + appleAuthReferences: [], + openURLHandlers: [], + status: "absent", + }; +} + +function describeUnmodeledApplePlatforms(platforms: string[]): string { + const hasVisionOS = platforms.some((platform) => + /^(?:visionos|xros|xrsimulator)$/i.test(platform), + ); + const hasMacCatalyst = platforms.some((platform) => /^maccatalyst$/i.test(platform)); + const remaining = platforms.filter( + (platform) => + !/^(?:visionos|xros|xrsimulator)$/i.test(platform) && !/^maccatalyst$/i.test(platform), + ); + return [ + ...(hasVisionOS ? ["visionOS"] : []), + ...(hasMacCatalyst ? ["Mac Catalyst"] : []), + ...remaining, + ].join(", "); +} + +function packageName(repositoryOrPath: string): string { + return basename(repositoryOrPath.replace(/\/$/, "")).replace(/\.git$/i, ""); +} + +async function inspectPackages( + root: string, + projectPath: string, + document: XCProjRecord, +): Promise { + const result: IOSPackageReference[] = []; + for (const item of xcprojPackages(document)) { + if (item.kind === "remote") { + const repository = sanitizeRepositoryURL(item.repository); + result.push({ + kind: "remote", + objectId: packageName(repository), + repository, + requirement: item.version + ? Object.fromEntries( + Object.entries(item.version).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ) + : undefined, + isClerk: isClerkIOSRepository(repository), + }); + continue; + } + const absolutePath = resolve(dirname(projectPath), item.path); + const safelyLocal = await pathIsSafelyWithinIOSRoot(root, absolutePath); + result.push({ + kind: "local", + objectId: packageName(item.path), + path: safelyLocal ? relativeIOSPath(root, absolutePath) : absolutePath, + isClerk: safelyLocal && (await localClerkIOSPackageIsStructurallyValid(root, absolutePath)), + }); + } + return result; +} + +function platformFiltersApply( + raw: unknown, + platform: IOSNativePlatform, +): { applies: boolean; complete: boolean } { + if (raw === undefined) return { applies: true, complete: true }; + if (!Array.isArray(raw) || !raw.every((value): value is string => typeof value === "string")) { + return { applies: false, complete: false }; + } + if (raw.length === 0) return { applies: true, complete: true }; + const recognized = raw.every((value) => + /^(?:ios|iphone(?:os|simulator)?|macos|maccatalyst|visionos|xros|xrsimulator|tvos|watchos)$/i.test( + value, + ), + ); + if (!recognized) return { applies: false, complete: false }; + return { + complete: true, + applies: + platform === "ios" + ? raw.some((value) => /^(?:ios|iphone(?:os|simulator)?)$/i.test(value)) + : raw.some((value) => /^macos$/i.test(value)), + }; +} + +function inspectTargetPackages( + root: string, + projectPath: string, + target: XCProjTarget, + packages: IOSPackageReference[], + diagnostics: IOSDiagnostic[], + platform: IOSNativePlatform, +): IOSClerkPackageState { + const memberState = ( + productName: "ClerkKit" | "ClerkKitUI", + ): { state: IOSProductLinkState; packageNames: string[] } => { + const matching = target.packageProductMembers.filter( + (member) => member["product-name"] === productName, + ); + if (matching.length === 0) return { state: "absent", packageNames: [] }; + let linked = false; + let evidenceComplete = true; + for (const member of matching) { + const phase = xcprojRecord(member["build-phase"]); + const applicability = platformFiltersApply(phase.platforms, platform); + evidenceComplete &&= applicability.complete; + linked ||= phase["build-phase"] === "frameworks" && applicability.applies; + } + if (!evidenceComplete) { + diagnostics.push({ + code: "clerk.package-unattributed", + severity: "warning", + message: `${target.name} contains an unrecognized platform filter on ${productName}.`, + evidence: [{ path: relativeIOSPath(root, resolve(projectPath, "project.xcproj")) }], + }); + } + return { + state: linked ? "linked" : "declared", + packageNames: matching.flatMap((member) => + typeof member.package === "string" ? [member.package] : [], + ), + }; + }; + + const clerkKit = memberState("ClerkKit"); + const clerkKitUI = memberState("ClerkKitUI"); + const hasProduct = clerkKit.state !== "absent" || clerkKitUI.state !== "absent"; + const attributedNames = [...clerkKit.packageNames, ...clerkKitUI.packageNames]; + const uniqueNames = new Set(attributedNames.map((name) => name.toLowerCase())); + const explicitPackage = + attributedNames.length > 0 && uniqueNames.size === 1 + ? packages.find((item) => item.objectId.toLowerCase() === attributedNames[0]!.toLowerCase()) + : undefined; + const declaredClerkPackage = packages.find((item) => item.isClerk); + let packageKind: IOSClerkPackageState["package"] = "absent"; + if (explicitPackage?.isClerk) packageKind = explicitPackage.kind; + else if (attributedNames.length === 0 && declaredClerkPackage) + packageKind = declaredClerkPackage.kind; + else if (hasProduct) { + packageKind = "unattributed"; + diagnostics.push({ + code: "clerk.package-unattributed", + severity: "warning", + message: `${target.name} declares a Clerk product without an attributable clerk-ios package reference.`, + evidence: [{ path: relativeIOSPath(root, resolve(projectPath, "project.xcproj")) }], + }); + } + return { package: packageKind, clerkKit: clerkKit.state, clerkKitUI: clerkKitUI.state }; +} + +function normalizedPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); +} + +function sourceReferencePath( + projectDirectory: string, + parent: string, + path: string, +): string | undefined { + if (/^<(?:PRODUCTS|SDK|DEVELOPER)>\//.test(path)) return undefined; + if (path.startsWith("/")) { + return resolve(projectDirectory, path.slice("/".length)); + } + if (path.startsWith("<")) return undefined; + return resolve(parent, path); +} + +async function collectSwiftFiles( + root: string, + directory: string, + groupRoot: string, + included: (relativePath: string) => boolean, + files: Map, + state: { complete: boolean }, + depth = 0, +): Promise { + if (depth > MAX_SOURCE_DEPTH || files.size >= MAX_SOURCE_FILES) { + state.complete = false; + return; + } + if (!(await pathIsSafelyWithinIOSRoot(root, directory))) { + state.complete = false; + return; + } + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + state.complete = false; + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (files.size >= MAX_SOURCE_FILES) { + state.complete = false; + return; + } + const absolutePath = resolve(directory, entry.name); + const pathFromGroup = normalizedPath(relative(groupRoot, absolutePath).split(sep).join("/")); + if (!included(pathFromGroup)) continue; + if (entry.isDirectory()) { + if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { + await collectSwiftFiles(root, absolutePath, groupRoot, included, files, state, depth + 1); + } + } else if (entry.isFile() && extname(entry.name) === ".swift") { + files.set(absolutePath, { absolutePath, relativePath: relativeIOSPath(root, absolutePath) }); + } else if (entry.isSymbolicLink() && extname(entry.name) === ".swift") { + state.complete = false; + } + } +} + +function folderMembership( + reference: XCProjRecord, + targetName: string, + platform: IOSNativePlatform | undefined, + state: { complete: boolean }, +): { member: boolean; included: (path: string) => boolean } { + let members: string[]; + try { + members = + reference["target-membership"] === undefined + ? [] + : xcprojStringArray(reference["target-membership"]); + } catch { + state.complete = false; + members = []; + } + const defaultMember = members.includes(targetName); + const inclusions = new Set(); + const exclusions = new Set(); + const filters = new Map(); + let exceptions: unknown[] = []; + try { + exceptions = + reference["membership-exceptions"] === undefined + ? [] + : xcprojArray(reference["membership-exceptions"]); + } catch { + state.complete = false; + } + for (const raw of exceptions) { + let exception: XCProjRecord; + try { + exception = xcprojRecord(raw); + if (exception.target !== targetName) continue; + const hasInclusions = Object.hasOwn(exception, "inclusions"); + const hasExclusions = Object.hasOwn(exception, "exclusions"); + if (hasInclusions === hasExclusions) { + state.complete = false; + continue; + } + for (const path of xcprojStringArray( + exception[hasInclusions ? "inclusions" : "exclusions"], + )) { + (hasInclusions ? inclusions : exclusions).add(normalizedPath(path)); + } + if (exception.platforms !== undefined) { + const byPath = xcprojRecord(exception.platforms); + for (const [path, value] of Object.entries(byPath)) + filters.set(normalizedPath(path), value); + } + } catch { + state.complete = false; + } + } + const matchesPath = (set: Set, path: string): boolean => + [...set].some((candidate) => path === candidate || path.startsWith(`${candidate}/`)); + return { + member: defaultMember || inclusions.size > 0, + included(path) { + const base = defaultMember ? !matchesPath(exclusions, path) : matchesPath(inclusions, path); + if (!base || !platform) return base; + for (const [candidate, raw] of filters) { + if (path !== candidate && !path.startsWith(`${candidate}/`)) continue; + const result = platformFiltersApply(raw, platform); + state.complete &&= result.complete; + return result.applies; + } + return true; + }, + }; +} + +function fileBelongsToSources( + reference: XCProjRecord, + targetName: string, + platform: IOSNativePlatform | undefined, + state: { complete: boolean }, +): boolean { + let memberships: unknown[]; + try { + memberships = + reference["target-membership"] === undefined + ? [] + : xcprojArray(reference["target-membership"]); + } catch { + state.complete = false; + return false; + } + for (const raw of memberships) { + let buildPhase: string | undefined; + let filters: unknown; + if (typeof raw === "string") buildPhase = raw; + else { + try { + const member = xcprojRecord(raw); + buildPhase = xcprojString(member["build-phase"]); + filters = member.platforms; + } catch { + state.complete = false; + continue; + } + } + if (buildPhase !== `${targetName}/compile-sources`) continue; + if (!platform) return true; + const result = platformFiltersApply(filters, platform); + state.complete &&= result.complete; + if (result.applies) return true; + } + return false; +} + +async function sourceFilesForTarget(options: { + root: string; + projectPath: string; + document: XCProjRecord; + targetName: string; + platform?: IOSNativePlatform; + diagnostics: IOSDiagnostic[]; +}): Promise<{ files: Array<{ absolutePath: string; relativePath: string }>; complete: boolean }> { + const { root, projectPath, document, targetName, platform, diagnostics } = options; + const state = { complete: true }; + const files = new Map(); + const projectDirectory = dirname(projectPath); + const visit = async (raw: unknown, parent: string): Promise => { + let reference: XCProjRecord; + try { + reference = xcprojRecord(raw); + } catch { + state.complete = false; + return; + } + const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const path = typeof reference.path === "string" ? reference.path : ""; + if (kind === "group") { + const groupDirectory = path ? sourceReferencePath(projectDirectory, parent, path) : parent; + if (!groupDirectory) return; + let children: unknown[]; + try { + children = reference.children === undefined ? [] : xcprojArray(reference.children); + } catch { + state.complete = false; + return; + } + for (const child of children) await visit(child, groupDirectory); + return; + } + if (kind === "folder") { + if (!path) { + state.complete = false; + return; + } + const directory = sourceReferencePath(projectDirectory, parent, path); + if (!directory) return; + const membership = folderMembership(reference, targetName, platform, state); + if (membership.member) { + await collectSwiftFiles(root, directory, directory, membership.included, files, state); + } + return; + } + if (kind !== "file") { + state.complete = false; + return; + } + if (!path || extname(path) !== ".swift") return; + if (!fileBelongsToSources(reference, targetName, platform, state)) return; + const absolutePath = sourceReferencePath(projectDirectory, parent, path); + if (!absolutePath || !(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + state.complete = false; + return; + } + files.set(absolutePath, { absolutePath, relativePath: relativeIOSPath(root, absolutePath) }); + }; + try { + for (const reference of xcprojArray(document.files)) await visit(reference, projectDirectory); + } catch { + state.complete = false; + } + if (files.size === 0 || !state.complete) { + diagnostics.push({ + code: "xcode.incomplete-source-membership", + severity: "info", + message: + files.size === 0 + ? `No Swift source membership could be resolved for ${targetName}; source-level Clerk checks may be incomplete.` + : `Swift source membership for ${targetName} was only partially inspected; absence checks are advisory.`, + evidence: [{ path: relativeIOSPath(root, resolve(projectPath, "project.xcproj")) }], + }); + } + return { + files: [...files.values()].sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + ), + complete: state.complete, + }; +} + +/** Converts a validated JSON-format Xcode document into the shared semantic inspection model. */ +export async function inspectXCProjProject(options: { + root: string; + projectPath: string; + documentPath: string; + document: XCProjRecord; + requestedTarget?: string; + requestedPlatform?: IOSNativePlatform; +}): Promise { + const { root, projectPath, documentPath, document, requestedTarget, requestedPlatform } = options; + const projectRelativePath = relativeIOSPath(root, projectPath); + const documentRelativePath = relativeIOSPath(root, documentPath); + const diagnostics: IOSDiagnostic[] = []; + const packages = await inspectPackages(root, projectPath, document); + const targets = xcprojTargets(document); + const appTargets: IOSAppTarget[] = []; + const appTargetCandidates: ParsedIOSProject["appTargetCandidates"] = []; + const sourceMemberships: IOSTargetSourceMembership[] = []; + + for (const target of targets) { + const ownership = await sourceFilesForTarget({ + root, + projectPath, + document, + targetName: target.name, + diagnostics: [], + }); + sourceMemberships.push({ + targetId: target.id, + targetName: target.name, + projectPath: projectRelativePath, + files: ownership.files, + complete: ownership.complete, + }); + } + + for (const target of targets) { + if ( + target.kind !== "native" || + !target.productType || + !APP_PRODUCT_TYPES.has(target.productType) + ) { + continue; + } + const configurationDiagnostics: IOSDiagnostic[] = []; + const inspectedConfigurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath: documentPath, + project: document, + target, + diagnostics: configurationDiagnostics, + platform: requestedPlatform, + }); + const concretePlatforms = new Set( + inspectedConfigurations.flatMap((configuration) => + configuration.platformEvidenceComplete && configuration.platform + ? [configuration.platform] + : [], + ), + ); + const hasUncertainConfiguration = inspectedConfigurations.some( + (configuration) => !configuration.platformEvidenceComplete, + ); + const hasResolvedUnsupportedConfiguration = inspectedConfigurations.some( + (configuration) => configuration.platformEvidenceComplete && !configuration.platform, + ); + const inferredPlatforms = new Set( + inspectedConfigurations.flatMap((configuration) => + configuration.platform ? [configuration.platform] : [], + ), + ); + const targetPlatform: IOSNativePlatform | undefined = requestedPlatform + ? requestedPlatform + : concretePlatforms.has("ios") + ? "ios" + : concretePlatforms.has("macos") + ? "macos" + : hasUncertainConfiguration || inspectedConfigurations.length === 0 + ? inferredPlatforms.has("macos") + ? "macos" + : "ios" + : undefined; + if (!targetPlatform) continue; + const platformEvidenceComplete = + inspectedConfigurations.length > 0 && + !hasUncertainConfiguration && + !hasResolvedUnsupportedConfiguration && + concretePlatforms.size === 1; + if (!platformEvidenceComplete) { + const unmodeledPlatforms = [ + ...new Set( + inspectedConfigurations.flatMap((configuration) => configuration.unmodeledPlatforms), + ), + ].sort(); + configurationDiagnostics.push({ + code: "xcode.unresolved-target-platform", + severity: "error", + message: + unmodeledPlatforms.length > 0 + ? `${target.name} also ships ${describeUnmodeledApplePlatforms(unmodeledPlatforms)}, which Clerk CLI can inspect but does not automate.` + : `${target.name} does not have one proven native platform across every build configuration.`, + remedy: + unmodeledPlatforms.length > 0 + ? "Automatic setup currently supports only non-Catalyst iOS and native macOS destinations." + : "Resolve SDKROOT, SUPPORTED_PLATFORMS, and SUPPORTS_MACCATALYST consistently before running Clerk setup.", + evidence: [{ path: documentRelativePath, objectId: target.id, keyPath: "build-settings" }], + }); + } + appTargetCandidates.push({ + targetId: target.id, + targetName: target.name, + projectPath: projectRelativePath, + platform: targetPlatform, + }); + if (requestedTarget && requestedTarget !== target.id && requestedTarget !== target.name) + continue; + diagnostics.push(...configurationDiagnostics); + const configurations = inspectedConfigurations.map((configuration) => configuration.model); + const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => + inspectedConfigurations.some((configuration) => + configuration.supportedPlatforms.includes(platform), + ), + ); + await attachEntitlements( + root, + projectPath, + targetPlatform, + configurations, + new Map( + inspectedConfigurations.map((configuration) => [ + configuration.model.name, + configuration.entitlementContexts, + ]), + ), + diagnostics, + ); + addBuildSettingConflictDiagnostics(target.name, configurations, diagnostics); + const ownership = sourceMemberships.find( + (membership) => + membership.targetId === target.id && membership.projectPath === projectRelativePath, + ); + const targetSourceDiagnostics: IOSDiagnostic[] = []; + const sources = await sourceFilesForTarget({ + root, + projectPath, + document, + targetName: target.name, + platform: targetPlatform, + diagnostics: targetSourceDiagnostics, + }); + sources.complete &&= ownership?.complete ?? false; + diagnostics.push(...targetSourceDiagnostics); + const swift = + sources.files.length > 0 + ? await inspectSwiftSources(sources.files, { + membershipComplete: sources.complete, + platform: targetPlatform, + }) + : emptySwiftInspection(); + appTargets.push({ + id: target.id, + name: target.name, + platform: targetPlatform, + supportedPlatforms, + platformEvidenceComplete, + projectPath: projectRelativePath, + configurations, + packages: inspectTargetPackages( + root, + projectPath, + target, + packages, + diagnostics, + targetPlatform, + ), + swift, + }); + } + + appTargets.sort( + (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + ); + appTargetCandidates.sort( + (left, right) => + left.targetName.localeCompare(right.targetName) || + left.targetId.localeCompare(right.targetId), + ); + return { + inspection: { + path: projectRelativePath, + projectFilePath: documentRelativePath, + projectFormat: "xcproj", + packages, + appTargetIds: appTargetCandidates.map((target) => target.targetId), + diagnostics, + }, + appTargets, + appTargetCandidates, + diagnostics, + sourceMemberships, + }; +} + +export function isXCProjInspectionError(error: unknown): error is XCProjError { + return error instanceof XCProjError; +} diff --git a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts new file mode 100644 index 000000000..804cf3150 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts @@ -0,0 +1,559 @@ +import { dirname, resolve } from "node:path"; +import semver from "semver"; +import { pathIsSafelyWithinIOSRoot } from "./discovery.ts"; +import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; +import { isClerkIOSRepository } from "./pbx.ts"; +import type { IOSNativePlatform } from "./types.ts"; +import { + applyXCProjValue, + parseXCProjSource, + type XCProjRecord, + type XCProjSwiftPackage, + xcprojPackages, + xcprojRecord, + xcprojString, + xcprojStringArray, + xcprojTargets, +} from "./xcproj.ts"; + +const APP_PRODUCT_TYPES = new Set(["application", "com.apple.product-type.application"]); +const CLERK_REPOSITORY = "https://github.com/clerk/clerk-ios"; +const PRODUCT_NAMES = new Set(["ClerkKit", "ClerkKitUI"]); +const RECOGNIZED_PLATFORM_FILTERS = new Set([ + "ios", + "macos", + "maccatalyst", + "tvos", + "watchos", + "xros", + "visionos", +]); + +export type XCProjSDKProduct = "ClerkKit" | "ClerkKitUI"; + +export type XCProjSDKInstallBlockerCode = + | "target-not-found" + | "ambiguous-target" + | "ambiguous-package" + | "external-path" + | "duplicate-product" + | "unattributed-product" + | "wrong-package" + | "ambiguous-frameworks-phase" + | "incompatible-sdk" + | "unsupported-project"; + +export interface XCProjSDKInstallOptions { + root: string; + /** Absolute path to the selected .xcodeproj wrapper. */ + projectPath: string; + source: Uint8Array; + targetId: string; + products: XCProjSDKProduct[]; + supportedPlatforms: IOSNativePlatform[]; + minimumVersion: string; + requiredCompatibilityVersion: string; + requirePrebuiltAuthCompatibility: boolean; + resolvedClerkVersions: { versions: string[]; unreadable: boolean }; +} + +export type XCProjSDKInstallPreparation = + | { + status: "blocked"; + blocker: { code: XCProjSDKInstallBlockerCode; message: string }; + } + | { status: "satisfied" } + | { status: "ready"; actions: string[]; candidateBytes: Uint8Array }; + +type VerifiedXCProjPackage = + | { + index: number; + kind: "remote"; + identity: string; + value: Extract; + } + | { + index: number; + kind: "local"; + identity: string; + value: Extract; + }; + +interface ProductMember { + index: number; + product: XCProjSDKProduct; + packageIdentity?: string; + platforms?: string[]; +} + +function blocked(code: XCProjSDKInstallBlockerCode, message: string): XCProjSDKInstallPreparation { + return { status: "blocked", blocker: { code, message } }; +} + +function packageIdentity(value: XCProjSwiftPackage): string { + const source = value.kind === "remote" ? value.repository : value.path; + const normalized = source.replaceAll("\\", "/").replace(/\/+$/, ""); + return (normalized.split("/").at(-1) ?? "").replace(/\.git$/i, "").toLowerCase(); +} + +async function verifiedPackages(options: XCProjSDKInstallOptions): Promise<{ + verified: VerifiedXCProjPackage[]; + unsafeLocalIdentity?: string; +}> { + const packages = xcprojPackages(parseXCProjSource(options.source).root); + const verified: VerifiedXCProjPackage[] = []; + let unsafeLocalIdentity: string | undefined; + for (const [index, item] of packages.entries()) { + const identity = packageIdentity(item); + if (item.kind === "remote") { + if (isClerkIOSRepository(item.repository)) { + verified.push({ index, kind: "remote", identity, value: item }); + } + continue; + } + const absolutePackagePath = resolve(dirname(options.projectPath), item.path); + if (!(await pathIsSafelyWithinIOSRoot(options.root, absolutePackagePath))) { + if (identity === "clerk-ios" || identity === "clerk") unsafeLocalIdentity = identity; + continue; + } + if (await localClerkIOSPackageIsStructurallyValid(options.root, absolutePackagePath)) { + verified.push({ index, kind: "local", identity, value: item }); + } + } + return { verified, unsafeLocalIdentity }; +} + +type RequirementProof = "compatible" | "incompatible" | "needs-resolution"; + +function requirementProof(version: XCProjRecord | undefined, required: string): RequirementProof { + if (!version) return "needs-resolution"; + const exact = typeof version.version === "string" ? version.version : undefined; + if (exact) { + return semver.valid(exact) && semver.gte(exact, required) ? "compatible" : "incompatible"; + } + + const major = + typeof version["up-to-next-major-version"] === "string" + ? version["up-to-next-major-version"] + : undefined; + const minor = + typeof version["up-to-next-minor-version"] === "string" + ? version["up-to-next-minor-version"] + : undefined; + const minimum = major ?? minor; + if (minimum) { + const parsed = semver.parse(minimum); + if (!parsed) return "incompatible"; + if (semver.gte(minimum, required)) return "compatible"; + const maximum = major ? `${parsed.major + 1}.0.0` : `${parsed.major}.${parsed.minor + 1}.0`; + return semver.lt(required, maximum) ? "needs-resolution" : "incompatible"; + } + + const compactRange = + typeof version["version-range"] === "string" ? version["version-range"] : undefined; + const compactBounds = compactRange?.split("..<", 2); + const rangeMinimum = + typeof version["version-range-min"] === "string" + ? version["version-range-min"] + : compactBounds?.length === 2 + ? compactBounds[0] + : undefined; + const rangeMaximum = + typeof version["version-range-max"] === "string" + ? version["version-range-max"] + : compactBounds?.length === 2 + ? compactBounds[1] + : undefined; + if (rangeMinimum && rangeMaximum) { + if (!semver.valid(rangeMinimum) || !semver.valid(rangeMaximum)) return "incompatible"; + if (semver.gte(rangeMinimum, required)) return "compatible"; + return semver.lt(required, rangeMaximum) ? "needs-resolution" : "incompatible"; + } + + // Branches, revisions, and any future range spelling need Package.resolved + // evidence before this writer may add modern Clerk products. + return "needs-resolution"; +} + +function requirementAllowsVersion(version: XCProjRecord | undefined, resolved: string): boolean { + if (!version || !semver.valid(resolved)) return false; + const exact = typeof version.version === "string" ? version.version : undefined; + if (exact) return semver.valid(exact) != null && semver.eq(exact, resolved); + const major = + typeof version["up-to-next-major-version"] === "string" + ? version["up-to-next-major-version"] + : undefined; + const minor = + typeof version["up-to-next-minor-version"] === "string" + ? version["up-to-next-minor-version"] + : undefined; + const minimum = major ?? minor; + if (minimum) { + const parsed = semver.parse(minimum); + if (!parsed || semver.lt(resolved, minimum)) return false; + const maximum = major ? `${parsed.major + 1}.0.0` : `${parsed.major}.${parsed.minor + 1}.0`; + return semver.lt(resolved, maximum); + } + const compactRange = + typeof version["version-range"] === "string" ? version["version-range"] : undefined; + const compactBounds = compactRange?.split("..<", 2); + const rangeMinimum = + typeof version["version-range-min"] === "string" + ? version["version-range-min"] + : compactBounds?.length === 2 + ? compactBounds[0] + : undefined; + const rangeMaximum = + typeof version["version-range-max"] === "string" + ? version["version-range-max"] + : compactBounds?.length === 2 + ? compactBounds[1] + : undefined; + return Boolean( + rangeMinimum && + rangeMaximum && + semver.valid(rangeMinimum) && + semver.valid(rangeMaximum) && + semver.gte(resolved, rangeMinimum) && + semver.lt(resolved, rangeMaximum), + ); +} + +function compatibilityBlocker( + selectedPackage: VerifiedXCProjPackage, + options: XCProjSDKInstallOptions, +): XCProjSDKInstallPreparation | undefined { + const prefix = options.requirePrebuiltAuthCompatibility + ? `ClerkKitUI's documented native components require clerk-ios ${options.requiredCompatibilityVersion} or newer.` + : `${options.products.join(" and ")} ${options.products.length === 1 ? "requires" : "require"} clerk-ios ${options.requiredCompatibilityVersion} or newer.`; + if (selectedPackage.kind === "local") { + if (!options.requirePrebuiltAuthCompatibility) return undefined; + return blocked( + "incompatible-sdk", + `${prefix} A local package's compiled target membership cannot be proven without executing its Package.swift manifest, so no source was changed. Use a compatible remote clerk-ios package or integrate AuthView manually.`, + ); + } + const proof = requirementProof( + selectedPackage.value.version, + options.requiredCompatibilityVersion, + ); + if (proof === "compatible") return undefined; + if (proof === "incompatible") { + return blocked( + "incompatible-sdk", + `${prefix} The existing remote package requirement excludes that version, so no source was changed. Update the package requirement and rerun clerk init.`, + ); + } + const resolved = options.resolvedClerkVersions; + if ( + !resolved.unreadable && + resolved.versions.length > 0 && + resolved.versions.every( + (version) => + semver.gte(version, options.requiredCompatibilityVersion) && + requirementAllowsVersion(selectedPackage.value.version, version), + ) + ) { + return undefined; + } + return blocked( + "incompatible-sdk", + `${prefix} Neither the existing remote requirement nor a canonical Package.resolved file proves a compatible version, so no source was changed. Require or resolve clerk-ios ${options.requiredCompatibilityVersion} or newer, then rerun clerk init.`, + ); +} + +function productMembers(target: XCProjRecord): ProductMember[] | XCProjSDKInstallPreparation { + const rawMembers = target["package-product-members"]; + if (rawMembers === undefined) return []; + if (!Array.isArray(rawMembers)) { + return blocked("unsupported-project", "The selected target has malformed package products."); + } + const result: ProductMember[] = []; + for (const [index, value] of rawMembers.entries()) { + const member = xcprojRecord(value); + const productName = xcprojString(member["product-name"]); + if (!PRODUCT_NAMES.has(productName)) continue; + const packageValue = member.package; + const buildPhase = xcprojRecord(member["build-phase"]); + const phase = xcprojString(buildPhase["build-phase"]); + if (phase !== "frameworks") { + return blocked( + "ambiguous-frameworks-phase", + `${productName} is attached to a non-Frameworks build phase.`, + ); + } + const platforms = + buildPhase.platforms === undefined + ? undefined + : xcprojStringArray(buildPhase.platforms).map((item) => item.toLowerCase()); + if (platforms?.some((item) => !RECOGNIZED_PLATFORM_FILTERS.has(item))) { + return blocked( + "unsupported-project", + `${productName} has an unrecognized platform filter in the selected target's Frameworks phase.`, + ); + } + result.push({ + index, + product: productName as XCProjSDKProduct, + packageIdentity: + packageValue === undefined ? undefined : xcprojString(packageValue).toLowerCase(), + platforms, + }); + } + return result; +} + +function appliesToPlatform(member: ProductMember, platform: IOSNativePlatform): boolean { + return member.platforms === undefined || member.platforms.includes(platform); +} + +function validateProductMembers( + members: ProductMember[], + selectedPackage: VerifiedXCProjPackage, + platforms: IOSNativePlatform[], +): XCProjSDKInstallPreparation | undefined { + for (const member of members) { + if (member.packageIdentity && member.packageIdentity !== selectedPackage.identity) { + return blocked( + "wrong-package", + `${member.product} points to a package other than the verified clerk-ios reference.`, + ); + } + } + for (const product of PRODUCT_NAMES) { + for (const platform of platforms) { + if ( + members.filter( + (member) => member.product === product && appliesToPlatform(member, platform), + ).length > 1 + ) { + return blocked( + "duplicate-product", + `The selected target links ${product} more than once for ${platform === "macos" ? "macOS" : "iOS"}.`, + ); + } + } + } + return undefined; +} + +function hasFrameworksPhase(target: ReturnType[number]): boolean { + return target.buildPhases.some((phase) => phase.kind === "frameworks"); +} + +function memberValue( + packageIdentity: string, + product: XCProjSDKProduct, + missingPlatforms: IOSNativePlatform[], + allPlatforms: IOSNativePlatform[], +): XCProjRecord { + const buildPhase: XCProjRecord = { "build-phase": "frameworks" }; + if (missingPlatforms.length !== allPlatforms.length) buildPhase.platforms = missingPlatforms; + return { + package: packageIdentity, + "product-name": product, + "build-phase": buildPhase, + }; +} + +/** + * Plans the format-specific part of the Clerk SDK mutation for project.xcproj. + * The caller owns target/platform inspection plus transactional installation. + */ +export async function prepareXCProjSDKInstall( + options: XCProjSDKInstallOptions, +): Promise { + const parsed = parseXCProjSource(options.source); + const targets = xcprojTargets(parsed.root); + const matches = targets + .map((target, index) => ({ target, index })) + .filter(({ target }) => target.id === options.targetId); + if (matches.length > 1) { + return blocked("ambiguous-target", `Target object ID ${options.targetId} is ambiguous.`); + } + const selected = matches[0]; + if ( + !selected || + !selected.target.productType || + !APP_PRODUCT_TYPES.has(selected.target.productType) + ) { + return blocked( + "target-not-found", + `The selected object ${options.targetId} is not an application target.`, + ); + } + + const packages = xcprojPackages(parsed.root); + const packageScan = await verifiedPackages(options); + if (packageScan.verified.length > 1) { + return blocked( + "ambiguous-package", + "More than one verified clerk-ios package reference exists in this Xcode project.", + ); + } + let selectedPackage = packageScan.verified[0]; + let candidate = parsed.source; + const actions: string[] = []; + if (!selectedPackage) { + if (packageScan.unsafeLocalIdentity) { + return blocked( + "external-path", + "A clerk-ios local package reference cannot be verified safely inside the project root.", + ); + } + const packageValue: XCProjRecord = { + kind: "remote", + repository: CLERK_REPOSITORY, + version: { "up-to-next-major-version": options.minimumVersion }, + }; + candidate = applyXCProjValue(candidate, ["packages", packages.length], packageValue); + selectedPackage = { + index: packages.length, + kind: "remote", + identity: "clerk-ios", + value: { + kind: "remote", + repository: CLERK_REPOSITORY, + version: packageValue.version as XCProjRecord, + traits: [], + raw: packageValue, + }, + }; + actions.push(`Add clerk-ios ${options.minimumVersion} or newer as a Swift package reference.`); + } else { + const compatibility = compatibilityBlocker(selectedPackage, options); + if (compatibility) return compatibility; + } + + const membersResult = productMembers(selected.target.raw); + if (!Array.isArray(membersResult)) return membersResult; + const memberBlocker = validateProductMembers( + membersResult, + selectedPackage, + options.supportedPlatforms, + ); + if (memberBlocker) return memberBlocker; + + if (!hasFrameworksPhase(selected.target)) { + const rawPhases = selected.target.raw["build-phases"]; + if (!Array.isArray(rawPhases)) { + return blocked( + "ambiguous-frameworks-phase", + "The selected target's build phases could not be updated safely.", + ); + } + candidate = applyXCProjValue( + candidate, + ["targets", selected.index, "build-phases", rawPhases.length], + "frameworks", + ); + actions.push("Create a Frameworks build phase for the selected target."); + } + + let memberCount = Array.isArray(selected.target.raw["package-product-members"]) + ? selected.target.raw["package-product-members"].length + : 0; + for (const product of options.products) { + const productMembers = membersResult.filter((member) => member.product === product); + const missingPlatforms = options.supportedPlatforms.filter( + (platform) => !productMembers.some((member) => appliesToPlatform(member, platform)), + ); + if (missingPlatforms.length === 0) continue; + const value = memberValue( + selectedPackage.identity, + product, + missingPlatforms, + options.supportedPlatforms, + ); + if (memberCount === 0 && selected.target.raw["package-product-members"] === undefined) { + candidate = applyXCProjValue( + candidate, + ["targets", selected.index, "package-product-members"], + [value], + ); + } else { + candidate = applyXCProjValue( + candidate, + ["targets", selected.index, "package-product-members", memberCount], + value, + ); + } + memberCount += 1; + actions.push(`Add and link ${product} in the selected target's Frameworks phase.`); + } + + if (actions.length === 0) return { status: "satisfied" }; + const candidateBytes = new TextEncoder().encode(candidate); + if ( + !validateXCProjSDKInstallPostcondition(candidateBytes, { + targetId: options.targetId, + products: options.products, + supportedPlatforms: options.supportedPlatforms, + packageIdentity: selectedPackage.identity, + }) + ) { + return blocked( + "unsupported-project", + "The proposed Xcode project did not pass package-linkage validation.", + ); + } + return { status: "ready", actions, candidateBytes }; +} + +export function validateXCProjSDKInstallPostcondition( + source: string | Uint8Array, + options: { + targetId: string; + products: XCProjSDKProduct[]; + supportedPlatforms: IOSNativePlatform[]; + packageIdentity?: string; + }, +): boolean { + try { + const parsed = parseXCProjSource(source); + const targets = xcprojTargets(parsed.root).filter((target) => target.id === options.targetId); + if ( + targets.length !== 1 || + !targets[0] || + !APP_PRODUCT_TYPES.has(targets[0].productType ?? "") + ) { + return false; + } + const membersResult = productMembers(targets[0].raw); + if (!Array.isArray(membersResult)) return false; + const packages = xcprojPackages(parsed.root); + const referencedIdentities = new Set( + membersResult + .flatMap((member) => (member.packageIdentity ? [member.packageIdentity] : [])) + .map((item) => item.toLowerCase()), + ); + const clerkPackages = packages.filter((item) => { + const identity = packageIdentity(item); + if (options.packageIdentity) return identity === options.packageIdentity.toLowerCase(); + return ( + (item.kind === "remote" && isClerkIOSRepository(item.repository)) || + (item.kind === "local" && referencedIdentities.has(identity)) + ); + }); + if (clerkPackages.length !== 1) return false; + const selectedPackage = clerkPackages[0]!; + const identity = packageIdentity(selectedPackage); + const verifiedPackage: VerifiedXCProjPackage = + selectedPackage.kind === "remote" + ? { index: 0, kind: "remote", identity, value: selectedPackage } + : { index: 0, kind: "local", identity, value: selectedPackage }; + if (validateProductMembers(membersResult, verifiedPackage, options.supportedPlatforms)) { + return false; + } + if (!hasFrameworksPhase(targets[0])) return false; + return options.products.every((product) => + options.supportedPlatforms.every((platform) => + membersResult.some( + (member) => member.product === product && appliesToPlatform(member, platform), + ), + ), + ); + } catch { + return false; + } +} diff --git a/packages/cli-core/src/commands/init/ios/xcproj.test.ts b/packages/cli-core/src/commands/init/ios/xcproj.test.ts new file mode 100644 index 000000000..eb736bcfc --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "bun:test"; +import { + applyXCProjValue, + parseXCProjSource, + XCProjError, + xcprojArray, + xcprojBuildPhases, + xcprojPackages, + xcprojRecord, + xcprojString, + xcprojStringArray, + xcprojTargets, +} from "./xcproj.ts"; + +const XCODE_GENERATED_PROJECT = `{ + // Xcode project metadata remains untouched by surgical edits. + "default-configuration": "Release", + "configurations": [ + "Debug", + { "name": "Release", "file": { "anchor": "App", "relative-path": "Config.xcconfig" } }, + ], + "localizations": { + "development": "en", + "supported": [ + "Base", + ], + }, + "packages": [ + { + "kind": "remote", + "repository": "https://github.com/clerk/clerk-ios", + "version": { + "up-to-next-major-version": "1.0.0", + }, + }, + { "kind": "local", "path": "LocalPackage" }, + ], + "files": [ + { "kind": "folder", "path": "App", "target-membership": [ "App" ] }, + ], + "targets": [ + { + "name": "App", + "id": "000000000000000100000000", + "product-type": "application", + "build-phases": [ + "compile-sources", + "frameworks", + { "kind": "script", "name": "Generate", "shell": "/bin/sh", "script": "true" }, + ], + "package-product-members": [ + { + "package": "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "frameworks" }, + }, + ], + "build-settings": { + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.App", + "SUPPORTED_PLATFORMS": [ "iphoneos", "iphonesimulator" ], + }, + }, + ], + "build-settings": { + "SDKROOT": "iphoneos", + }, +} +`; + +describe("parseXCProjSource", () => { + test("parses the canonical Xcode JSON shape with comments and trailing commas", () => { + const parsed = parseXCProjSource(XCODE_GENERATED_PROJECT); + + expect(parsed.source).toBe(XCODE_GENERATED_PROJECT); + expect(parsed.root["default-configuration"]).toBe("Release"); + expect(xcprojTargets(parsed.root)).toEqual([ + expect.objectContaining({ + name: "App", + id: "000000000000000100000000", + kind: "native", + productType: "application", + buildSettings: { + PRODUCT_BUNDLE_IDENTIFIER: "com.example.App", + SUPPORTED_PLATFORMS: ["iphoneos", "iphonesimulator"], + }, + }), + ]); + expect(xcprojBuildPhases(xcprojTargets(parsed.root)[0]!.raw)).toEqual([ + { kind: "compile-sources", raw: "compile-sources" }, + { kind: "frameworks", raw: "frameworks" }, + expect.objectContaining({ kind: "script", name: "Generate" }), + ]); + expect(xcprojPackages(parsed.root)).toEqual([ + expect.objectContaining({ + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + version: { "up-to-next-major-version": "1.0.0" }, + traits: [], + }), + expect.objectContaining({ kind: "local", path: "LocalPackage", traits: [] }), + ]); + }); + + test("accepts bounded UTF-8 bytes", () => { + const bytes = new TextEncoder().encode(XCODE_GENERATED_PROJECT); + expect(parseXCProjSource(bytes).root["default-configuration"]).toBe("Release"); + }); + + test("rejects input before decoding when it exceeds the byte bound", () => { + expect(() => parseXCProjSource(XCODE_GENERATED_PROJECT, { maxBytes: 16 })).toThrow( + expect.objectContaining({ code: "too-large" }), + ); + }); + + test("rejects invalid UTF-8", () => { + expect(() => parseXCProjSource(Uint8Array.from([0xff]))).toThrow( + expect.objectContaining({ code: "invalid-utf8" }), + ); + }); + + test("rejects duplicate keys instead of accepting parser last-write behavior", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '"default-configuration": "Release",', + '"default-configuration": "Debug",\n "default-configuration": "Release",', + ); + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "duplicate-key" }), + ); + }); + + test("fails closed on required capabilities", () => { + const source = XCODE_GENERATED_PROJECT.replace( + "{\n", + '{\n "required-capabilities": [ "future-xcode-feature" ],\n', + ); + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "unsupported-capability" }), + ); + }); + + test("rejects malformed compact build phases", () => { + const source = XCODE_GENERATED_PROJECT.replace('"compile-sources",', '"script",'); + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "invalid-schema" }), + ); + }); + + test("rejects malformed string arrays without silently filtering entries", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '[ "iphoneos", "iphonesimulator" ]', + '[ "iphoneos", false ]', + ); + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "invalid-schema" }), + ); + }); + + test("never echoes project contents in parse errors", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '"SDKROOT": "iphoneos",', + '"SDKROOT": "super-secret-value", BROKEN', + ); + try { + parseXCProjSource(source); + throw new Error("expected parsing to fail"); + } catch (error) { + expect(error).toBeInstanceOf(XCProjError); + expect((error as Error).message).not.toContain("super-secret-value"); + expect((error as XCProjError).code).toBe("invalid-syntax"); + } + }); +}); + +describe("strict Xcode JSON value helpers", () => { + test("return only exact requested shapes", () => { + expect(xcprojRecord({ key: "value" })).toEqual({ key: "value" }); + expect(xcprojArray(["value"])).toEqual(["value"]); + expect(xcprojString("value")).toBe("value"); + expect(xcprojStringArray(["one", "two"])).toEqual(["one", "two"]); + }); + + test("do not coerce scalars or partially valid arrays", () => { + expect(() => xcprojRecord([])).toThrow(XCProjError); + expect(() => xcprojArray({ 0: "value" })).toThrow(XCProjError); + expect(() => xcprojString(1)).toThrow(XCProjError); + expect(() => xcprojStringArray(["one", 2])).toThrow(XCProjError); + }); +}); + +describe("applyXCProjValue", () => { + test("replaces one value without reserializing comments or unrelated bytes", () => { + const candidate = applyXCProjValue( + XCODE_GENERATED_PROJECT, + ["targets", 0, "build-settings", "PRODUCT_BUNDLE_IDENTIFIER"], + "com.example.Updated", + ); + + expect(candidate).toBe( + XCODE_GENERATED_PROJECT.replace("com.example.App", "com.example.Updated"), + ); + expect(candidate).toContain("// Xcode project metadata remains untouched"); + }); + + test("inserts a setting while preserving existing comments", () => { + const candidate = applyXCProjValue( + XCODE_GENERATED_PROJECT, + ["targets", 0, "build-settings", "CODE_SIGN_ENTITLEMENTS"], + "App/App.entitlements", + ); + + expect(candidate).toContain('"CODE_SIGN_ENTITLEMENTS": "App/App.entitlements"'); + expect(candidate).toContain("// Xcode project metadata remains untouched"); + expect(parseXCProjSource(candidate).root.targets).toBeArray(); + }); + + test("refuses an edit that violates required root schema", () => { + expect(() => + applyXCProjValue(XCODE_GENERATED_PROJECT, ["default-configuration"], undefined), + ).toThrow(expect.objectContaining({ code: "invalid-schema" })); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/xcproj.ts b/packages/cli-core/src/commands/init/ios/xcproj.ts new file mode 100644 index 000000000..04a717444 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/xcproj.ts @@ -0,0 +1,460 @@ +import { + applyEdits, + getNodeValue, + modify, + parseTree, + type JSONPath, + type Node, + type ParseError, +} from "jsonc-parser"; + +export const MAX_XCPROJ_BYTES = 15_000_000; + +export type XCProjRecord = Record; + +export type XCProjErrorCode = + | "too-large" + | "invalid-utf8" + | "invalid-syntax" + | "duplicate-key" + | "invalid-schema" + | "unsupported-capability" + | "unsafe-edit"; + +export class XCProjError extends Error { + readonly code: XCProjErrorCode; + + constructor(code: XCProjErrorCode, message: string) { + super(message); + this.name = "XCProjError"; + this.code = code; + } +} + +export interface ParseXCProjOptions { + maxBytes?: number; +} + +export interface ParsedXCProjSource { + /** The validated source text. Never include it in diagnostics or logs. */ + source: string; + root: XCProjRecord; +} + +export type XCProjBuildPhaseKind = + | "apple-script" + | "frameworks" + | "headers" + | "java-archive" + | "resources" + | "rez" + | "compile-sources" + | "copy" + | "script"; + +export interface XCProjBuildPhase { + kind: XCProjBuildPhaseKind; + name?: string; + id?: string; + raw: string | XCProjRecord; +} + +export type XCProjSwiftPackage = + | { + kind: "remote"; + repository: string; + version?: XCProjRecord; + traits: string[]; + raw: XCProjRecord; + } + | { + kind: "local"; + path: string; + traits: string[]; + raw: XCProjRecord; + }; + +export interface XCProjTarget { + name: string; + id: string; + kind: "native" | "aggregate" | "external-build-system"; + productType?: string; + buildSettings: Record; + buildPhases: XCProjBuildPhase[]; + packageProductMembers: XCProjRecord[]; + raw: XCProjRecord; +} + +const BUILD_PHASE_KINDS = new Set([ + "apple-script", + "frameworks", + "headers", + "java-archive", + "resources", + "rez", + "compile-sources", + "copy", + "script", +]); + +const COMPACT_BUILD_PHASE_KINDS = new Set([ + "frameworks", + "headers", + "java-archive", + "resources", + "rez", + "compile-sources", +]); + +const TARGET_KINDS = new Set(["native", "aggregate", "external-build-system"] as const); +const PACKAGE_VERSION_KEYS = [ + "revision", + "branch", + "version", + "version-range", + "version-range-min", + "version-range-max", + "up-to-next-minor-version", + "up-to-next-major-version", +] as const; + +function schemaError(): never { + throw new XCProjError( + "invalid-schema", + "project.xcproj contains a value with an unsupported schema shape.", + ); +} + +export function xcprojRecord(value: unknown): XCProjRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) schemaError(); + return value as XCProjRecord; +} + +export function xcprojArray(value: unknown): unknown[] { + if (!Array.isArray(value)) schemaError(); + return value; +} + +export function xcprojString(value: unknown): string { + if (typeof value !== "string") schemaError(); + return value; +} + +export function xcprojStringArray(value: unknown): string[] { + const values = xcprojArray(value); + if (!values.every((item): item is string => typeof item === "string")) schemaError(); + return values; +} + +function optionalString(record: XCProjRecord, key: string): string | undefined { + const value = record[key]; + return value === undefined ? undefined : xcprojString(value); +} + +function optionalStringArray(record: XCProjRecord, key: string): string[] { + const value = record[key]; + return value === undefined ? [] : xcprojStringArray(value); +} + +function recordArray(value: unknown): XCProjRecord[] { + return xcprojArray(value).map(xcprojRecord); +} + +function optionalRecordArray(record: XCProjRecord, key: string): XCProjRecord[] { + const value = record[key]; + return value === undefined ? [] : recordArray(value); +} + +function validateBuildSettings(value: unknown): Record { + const record = xcprojRecord(value); + const settings: Record = {}; + for (const [key, setting] of Object.entries(record)) { + settings[key] = Array.isArray(setting) ? xcprojStringArray(setting) : xcprojString(setting); + } + return settings; +} + +function validateConfiguration(value: unknown): void { + if (typeof value === "string") return; + const record = xcprojRecord(value); + xcprojString(record.name); + optionalString(record, "id"); + if (record.file !== undefined && typeof record.file !== "string") xcprojRecord(record.file); +} + +function normalizeBuildPhase(value: unknown): XCProjBuildPhase { + if (typeof value === "string") { + if (!COMPACT_BUILD_PHASE_KINDS.has(value as XCProjBuildPhaseKind)) schemaError(); + return { kind: value as XCProjBuildPhaseKind, raw: value }; + } + + const record = xcprojRecord(value); + const kind = xcprojString(record.kind) as XCProjBuildPhaseKind; + if (!BUILD_PHASE_KINDS.has(kind)) schemaError(); + return { + kind, + name: optionalString(record, "name"), + id: optionalString(record, "id"), + raw: record, + }; +} + +export function xcprojBuildPhases(target: XCProjRecord): XCProjBuildPhase[] { + const value = target["build-phases"]; + return value === undefined ? [] : xcprojArray(value).map(normalizeBuildPhase); +} + +function validatePackageVersion(value: unknown): XCProjRecord { + const version = xcprojRecord(value); + const presentKeys = PACKAGE_VERSION_KEYS.filter((key) => version[key] !== undefined); + const hasSplitRange = + presentKeys.includes("version-range-min") || presentKeys.includes("version-range-max"); + const expectedKeyCount = hasSplitRange ? 2 : 1; + if (presentKeys.length !== expectedKeyCount) schemaError(); + if ( + hasSplitRange && + (!presentKeys.includes("version-range-min") || !presentKeys.includes("version-range-max")) + ) { + schemaError(); + } + for (const key of presentKeys) xcprojString(version[key]); + return version; +} + +function normalizePackage(value: unknown): XCProjSwiftPackage { + const record = xcprojRecord(value); + const kind = xcprojString(record.kind); + const traits = optionalStringArray(record, "traits"); + if (kind === "remote") { + const version = + record.version === undefined ? undefined : validatePackageVersion(record.version); + return { + kind, + repository: xcprojString(record.repository), + version, + traits, + raw: record, + }; + } + if (kind === "local") { + return { kind, path: xcprojString(record.path), traits, raw: record }; + } + return schemaError(); +} + +export function xcprojPackages(root: XCProjRecord): XCProjSwiftPackage[] { + const value = root.packages; + return value === undefined ? [] : xcprojArray(value).map(normalizePackage); +} + +function validatePackageProductMember(value: unknown): XCProjRecord { + const member = xcprojRecord(value); + xcprojString(member["product-name"]); + optionalString(member, "package"); + optionalString(member, "id"); + optionalString(member, "product-type"); + const buildPhase = xcprojRecord(member["build-phase"]); + optionalString(buildPhase, "id"); + xcprojString(buildPhase["build-phase"]); + optionalStringArray(buildPhase, "platforms"); + return member; +} + +function normalizeTarget(value: unknown): XCProjTarget { + const target = xcprojRecord(value); + const kind = (optionalString(target, "kind") ?? "native") as XCProjTarget["kind"]; + if (!TARGET_KINDS.has(kind)) schemaError(); + if (target["product-type"] !== undefined && target["full-product-type"] !== undefined) { + schemaError(); + } + const configurations = target["specialized-configurations"]; + if (configurations !== undefined) xcprojArray(configurations).forEach(validateConfiguration); + const dependencies = target.dependencies; + if (dependencies !== undefined) { + for (const dependency of xcprojArray(dependencies)) { + if (typeof dependency !== "string") xcprojRecord(dependency); + } + } + + return { + name: xcprojString(target.name), + id: xcprojString(target.id), + kind, + productType: + optionalString(target, "product-type") ?? optionalString(target, "full-product-type"), + buildSettings: + target["build-settings"] === undefined ? {} : validateBuildSettings(target["build-settings"]), + buildPhases: xcprojBuildPhases(target), + packageProductMembers: optionalRecordArray(target, "package-product-members").map( + validatePackageProductMember, + ), + raw: target, + }; +} + +export function xcprojTargets(root: XCProjRecord): XCProjTarget[] { + const value = root.targets; + return value === undefined ? [] : xcprojArray(value).map(normalizeTarget); +} + +function validateFileReference(value: unknown): void { + const reference = xcprojRecord(value); + optionalString(reference, "kind"); + optionalString(reference, "path"); + optionalString(reference, "name"); + optionalString(reference, "id"); + const children = reference.children; + if (children !== undefined) xcprojArray(children).forEach(validateFileReference); + const membership = reference["target-membership"]; + if (membership !== undefined) { + for (const item of xcprojArray(membership)) { + if (typeof item !== "string") xcprojRecord(item); + } + } +} + +function validateRoot(root: XCProjRecord): void { + const capabilities = + root["required-capabilities"] === undefined + ? [] + : xcprojStringArray(root["required-capabilities"]); + if (capabilities.length > 0) { + throw new XCProjError( + "unsupported-capability", + "project.xcproj requires an unsupported Xcode capability.", + ); + } + + xcprojString(root["default-configuration"]); + const localizations = xcprojRecord(root.localizations); + xcprojString(localizations.development); + optionalStringArray(localizations, "supported"); + + xcprojArray(root.configurations ?? []).forEach(validateConfiguration); + xcprojArray(root.files).forEach(validateFileReference); + xcprojPackages(root); + xcprojTargets(root); + if (root["build-settings"] !== undefined) validateBuildSettings(root["build-settings"]); + optionalRecordArray(root, "imported-products"); + + if ( + root["build-independent-targets-in-parallel"] !== undefined && + typeof root["build-independent-targets-in-parallel"] !== "boolean" + ) { + schemaError(); + } + for (const key of [ + "id", + "root-group-debug-id", + "configuration-list-debug-id", + "organization", + "class-prefix", + "products-group", + "last-upgrade", + "last-swift-update", + "last-swift-migration", + ]) { + if (root[key] !== undefined && key !== "products-group") optionalString(root, key); + } + if ( + root["products-group"] !== undefined && + typeof root["products-group"] !== "string" && + !Array.isArray(root["products-group"]) + ) { + schemaError(); + } + if (Array.isArray(root["products-group"])) xcprojStringArray(root["products-group"]); +} + +function validateNoDuplicateKeys(node: Node): void { + if (node.type === "object") { + const seen = new Set(); + for (const property of node.children ?? []) { + const key = property.children?.[0]?.value; + const value = property.children?.[1]; + if (typeof key !== "string" || !value) schemaError(); + if (seen.has(key)) { + throw new XCProjError("duplicate-key", "project.xcproj contains duplicate object keys."); + } + seen.add(key); + validateNoDuplicateKeys(value); + } + return; + } + if (node.type === "array") { + for (const child of node.children ?? []) validateNoDuplicateKeys(child); + } +} + +function sourceText(source: string | Uint8Array, maxBytes: number): string { + const byteLength = typeof source === "string" ? Buffer.byteLength(source) : source.byteLength; + if (byteLength > maxBytes) { + throw new XCProjError( + "too-large", + `project.xcproj exceeds the ${maxBytes} byte inspection limit.`, + ); + } + if (typeof source === "string") return source; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(source); + } catch { + throw new XCProjError("invalid-utf8", "project.xcproj is not valid UTF-8."); + } +} + +export function parseXCProjSource( + source: string | Uint8Array, + options: ParseXCProjOptions = {}, +): ParsedXCProjSource { + const text = sourceText(source, options.maxBytes ?? MAX_XCPROJ_BYTES); + const errors: ParseError[] = []; + const tree = parseTree(text, errors, { + allowTrailingComma: true, + disallowComments: false, + allowEmptyContent: false, + }); + if (!tree || errors.length > 0) { + throw new XCProjError("invalid-syntax", "project.xcproj is not valid canonical Xcode JSON."); + } + if (tree.type !== "object") schemaError(); + validateNoDuplicateKeys(tree); + const root = xcprojRecord(getNodeValue(tree)); + validateRoot(root); + return { source: text, root }; +} + +export interface ApplyXCProjValueOptions extends ParseXCProjOptions { + formatting?: { + insertSpaces?: boolean; + tabSize?: number; + eol?: string; + }; +} + +/** + * Applies one JSON-path edit while leaving unrelated bytes and comments intact. + * Passing `undefined` removes the value at `path`. + */ +export function applyXCProjValue( + source: string | Uint8Array, + path: JSONPath, + value: unknown, + options: ApplyXCProjValueOptions = {}, +): string { + const parsed = parseXCProjSource(source, options); + try { + const edits = modify(parsed.source, [...path], value, { + formattingOptions: { + insertSpaces: options.formatting?.insertSpaces ?? true, + tabSize: options.formatting?.tabSize ?? 2, + eol: options.formatting?.eol ?? (parsed.source.includes("\r\n") ? "\r\n" : "\n"), + }, + }); + const candidate = applyEdits(parsed.source, edits); + parseXCProjSource(candidate, options); + return candidate; + } catch (error) { + if (error instanceof XCProjError) throw error; + throw new XCProjError("unsafe-edit", "Unable to edit project.xcproj safely."); + } +} diff --git a/test/e2e/fixtures/ios-json/MyApp.xcodeproj/project.xcproj b/test/e2e/fixtures/ios-json/MyApp.xcodeproj/project.xcproj new file mode 100644 index 000000000..bc2d6ccad --- /dev/null +++ b/test/e2e/fixtures/ios-json/MyApp.xcodeproj/project.xcproj @@ -0,0 +1,59 @@ +{ + "default-configuration": "Release", + "configurations": [ + "Debug", + "Release", + ], + "localizations": { + "development": "en", + "supported": [ + "Base", + ], + }, + "files": [ + { "kind": "folder", "path": "MyApp", "target-membership": [ "MyApp" ] }, + { + "kind": "group", + "name": "Products", + "children": [ + { "path": "/MyApp.app", "id": "C1E000000000000000000002", "type": "wrapper.application", "index": false }, + ], + }, + ], + "targets": [ + { + "name": "MyApp", + "id": "C1E000000000000000000001", + "product": "Products/MyApp.app", + "product-type": "application", + "build-phases": [ + "compile-sources", + "frameworks", + "resources", + ], + "build-settings": { + "CODE_SIGN_ENTITLEMENTS": "MyApp/MyApp.entitlements", + "CODE_SIGN_STYLE": "Automatic", + "CURRENT_PROJECT_VERSION": "1", + "DEVELOPMENT_TEAM": "ABCDE12345", + "GENERATE_INFOPLIST_FILE": "YES", + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation": "YES", + "INFOPLIST_KEY_UILaunchScreen_Generation": "YES", + "IPHONEOS_DEPLOYMENT_TARGET": "17.0", + "LD_RUNPATH_SEARCH_PATHS": "@executable_path/Frameworks", + "MARKETING_VERSION": "1.0", + "PRODUCT_BUNDLE_IDENTIFIER": "com.example.MyApp", + "PRODUCT_NAME": "$(TARGET_NAME)", + "SUPPORTED_PLATFORMS": "iphoneos iphonesimulator", + "SWIFT_EMIT_LOC_STRINGS": "YES", + "SWIFT_VERSION": "6.0", + }, + }, + ], + "build-settings": { + "ALWAYS_SEARCH_USER_PATHS": "NO", + "GCC_OPTIMIZATION_LEVEL[config=Debug]": "0", + "SDKROOT": "iphoneos", + "SWIFT_OPTIMIZATION_LEVEL[config=Debug]": "-Onone", + }, +} diff --git a/test/e2e/fixtures/ios-json/MyApp/ContentView.swift b/test/e2e/fixtures/ios-json/MyApp/ContentView.swift new file mode 100644 index 000000000..f6a40a672 --- /dev/null +++ b/test/e2e/fixtures/ios-json/MyApp/ContentView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/test/e2e/fixtures/ios-json/MyApp/MyApp.entitlements b/test/e2e/fixtures/ios-json/MyApp/MyApp.entitlements new file mode 100644 index 000000000..f76746c62 --- /dev/null +++ b/test/e2e/fixtures/ios-json/MyApp/MyApp.entitlements @@ -0,0 +1,7 @@ + + + +application-identifierLEGACY1234.com.example.MyApp +com.apple.developer.team-identifierABCDE12345 +com.apple.developer.associated-domainswebcredentials:clerk.example.test + diff --git a/test/e2e/fixtures/ios-json/MyApp/MyAppApp.swift b/test/e2e/fixtures/ios-json/MyApp/MyAppApp.swift new file mode 100644 index 000000000..6ca35d65c --- /dev/null +++ b/test/e2e/fixtures/ios-json/MyApp/MyAppApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/test/e2e/fixtures/ios-json/README.md b/test/e2e/fixtures/ios-json/README.md new file mode 100644 index 000000000..103e2a243 --- /dev/null +++ b/test/e2e/fixtures/ios-json/README.md @@ -0,0 +1,17 @@ +# Xcode JSON iOS fixture + +A minimal, Clerk-authored SwiftUI application using Xcode's JSON-based +`project.xcproj` project configuration format. It mirrors the source layout and +identity evidence of the classic [`../ios`](../ios) fixture so inspection and +mutation tests can compare the two formats without changing their product +scenario. + +The project document's schema and formatting were verified with Xcode 27.2 +beta (`27B5019j`) by converting the matching classic fixture with +`xcodebuild -convert-project xcproj`, formatting it with `xcprojformatter`, and +building the CLI-mutated project for the iOS Simulator. All names, identifiers, +settings, entitlements, and Swift sources in this fixture were authored for +Clerk; no third-party application code is included. + +Keep the trailing commas in `project.xcproj`. Xcode's JSON project format +supports them even though strict JSON parsers do not. From 03b41e1e3367cc3e0762ad8dd3dfd4b716caca73 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 21:24:58 -0400 Subject: [PATCH 02/10] fix: harden Xcode JSON project handling --- .../commands/init/ios/direct-config.test.ts | 26 +++++- .../src/commands/init/ios/inspect.test.ts | 19 ++++ .../src/commands/init/ios/install-sdk.test.ts | 89 ++++++++++++++++++- .../src/commands/init/ios/test-helpers.ts | 56 +++++++++++- .../init/ios/xcproj-build-settings.test.ts | 67 +++++++++++++- .../init/ios/xcproj-build-settings.ts | 34 +++++-- .../src/commands/init/ios/xcproj-inspect.ts | 6 +- .../commands/init/ios/xcproj-install-sdk.ts | 6 +- 8 files changed, 289 insertions(+), 14 deletions(-) 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 d1ade23b6..7fb71d895 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 @@ -24,7 +24,13 @@ import { type IOSDirectConfigBlockerCode, } from "./direct-config.ts"; import type { PbxObjects } from "./pbx.ts"; -import { createIOSFixture, IOS_FIXTURE_IDS, treeDigest } from "./test-helpers.ts"; +import { + addNestedSharedEntryToIOSJSONFixture, + createIOSFixture, + createIOSJSONFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; const DEVELOPMENT_KEY = `pk_test_${Buffer.from("direct-config.clerk.accounts.dev$").toString("base64")}`; const OTHER_DEVELOPMENT_KEY = `pk_test_${Buffer.from("other-app.clerk.accounts.dev$").toString("base64")}`; @@ -768,6 +774,24 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("refuses a nested JSON folder inclusion shared with another target", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-shared-"); + await createIOSJSONFixture(root); + const { primaryTargetId, sharedSourcePath } = await addNestedSharedEntryToIOSJSONFixture(root); + const absoluteSharedSourcePath = join(root, sharedSourcePath); + const before = await readFile(absoluteSharedSourcePath); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: primaryTargetId, + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(absoluteSharedSourcePath)).toEqual(before); + }); + test.each(["build-phases", "source-phase-files"] as const)( "refuses mutation when shared-source ownership uses malformed %s", async (collection) => { 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 3988cdcf4..566c4c5c4 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -8,6 +8,7 @@ import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; import { recoverIOSFileTransactions } from "./file-transaction.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; import { + addNestedSharedEntryToIOSJSONFixture, addVisionOSDestinationsToFixture, convertIOSFixtureToMultiplatform, createIOSFixture, @@ -923,6 +924,24 @@ describe("inspectIOSProject", () => { expect(inspection.diagnostics).toEqual([]); }); + test("traverses nested explicit JSON folder inclusions when proving source ownership", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-nested-inclusion-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const { primaryTargetId, secondaryTargetId, sharedSourcePath } = + await addNestedSharedEntryToIOSJSONFixture(root); + + const memberships = await inspectIOSSourceMembership(root); + const owners = memberships.filter((membership) => + membership.files.some((file) => file.relativePath === sharedSourcePath), + ); + + expect(owners.map((membership) => membership.targetId).sort()).toEqual( + [primaryTargetId, secondaryTargetId].sort(), + ); + expect(owners.every((membership) => membership.complete)).toBe(true); + }); + test("extracts the App ID Prefix when Bundle ID casing differs", async () => { const root = await fixture({ complete: true }); const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 6cbd01142..af7da987c 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -32,7 +32,7 @@ import { IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; -import { parseXCProjSource, xcprojPackages, xcprojTargets } from "./xcproj.ts"; +import { applyXCProjValue, parseXCProjSource, xcprojPackages, xcprojTargets } from "./xcproj.ts"; const temporaryDirectories: string[] = []; @@ -297,6 +297,93 @@ describe("iOS Clerk SDK installer", () => { expect(await readFile(path)).toEqual(installedBytes); }); + test("treats an empty Xcode JSON product platform filter as unrestricted", async () => { + const root = await temporaryRoot("clerk-xcproj-empty-platforms-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await Bun.file(path).text(); + source = applyXCProjValue( + source, + ["packages"], + [ + { + kind: "remote", + repository: "https://github.com/clerk/clerk-ios.git", + version: { "up-to-next-major-version": DEFAULT_CLERK_IOS_MINIMUM_VERSION }, + }, + ], + ); + source = applyXCProjValue( + source, + ["targets", 0, "package-product-members"], + [ + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "frameworks", platforms: [] }, + }, + ], + ); + await Bun.write(path, source); + const before = await readFile(path); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("satisfied"); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "satisfied" }); + expect(await readFile(path)).toEqual(before); + }); + + test("rejects duplicate unrestricted Xcode JSON product links", async () => { + const root = await temporaryRoot("clerk-xcproj-duplicate-platforms-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await Bun.file(path).text(); + source = applyXCProjValue( + source, + ["packages"], + [ + { + kind: "remote", + repository: "https://github.com/clerk/clerk-ios.git", + version: { "up-to-next-major-version": DEFAULT_CLERK_IOS_MINIMUM_VERSION }, + }, + ], + ); + source = applyXCProjValue( + source, + ["targets", 0, "package-product-members"], + [ + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "frameworks", platforms: [] }, + }, + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "frameworks" }, + }, + ], + ); + await Bun.write(path, source); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "duplicate-product" }], + }); + }); + test("rejects a stale project.xcproj plan without overwriting newer bytes", async () => { const root = await temporaryRoot("clerk-xcproj-stale-"); await createIOSJSONFixture(root); diff --git a/packages/cli-core/src/commands/init/ios/test-helpers.ts b/packages/cli-core/src/commands/init/ios/test-helpers.ts index e11fd2443..26d3c6040 100644 --- a/packages/cli-core/src/commands/init/ios/test-helpers.ts +++ b/packages/cli-core/src/commands/init/ios/test-helpers.ts @@ -1,7 +1,18 @@ -import { cp, lstat, mkdir, readdir, readFile, readlink, rm, writeFile } from "node:fs/promises"; +import { + cp, + lstat, + mkdir, + readdir, + readFile, + readlink, + rename, + rm, + writeFile, +} from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import type { PbxObjects } from "./pbx.ts"; +import { applyXCProjValue } from "./xcproj.ts"; const IDS = { project: "AAAAAAAAAAAAAAAAAAAAAAAA", @@ -338,6 +349,49 @@ export async function createIOSJSONFixture(root: string): Promise { await cp(IOS_JSON_FIXTURE, root, { recursive: true }); } +/** Adds a second JSON-format target that explicitly includes a nested shared app entry source. */ +export async function addNestedSharedEntryToIOSJSONFixture(root: string): Promise<{ + primaryTargetId: string; + secondaryTargetId: string; + sharedSourcePath: string; +}> { + const primaryTargetId = "C1E000000000000000000001"; + const secondaryTargetId = "C1E000000000000000000099"; + const sharedSourcePath = "MyApp/Nested/MyAppApp.swift"; + await mkdir(join(root, "MyApp", "Nested"), { recursive: true }); + await rename( + join(root, "MyApp", "MyAppApp.swift"), + join(root, "MyApp", "Nested", "MyAppApp.swift"), + ); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await readFile(projectPath, "utf8"); + source = applyXCProjValue(source, ["files", 0], { + kind: "folder", + path: "MyApp", + "target-membership": ["MyApp"], + "membership-exceptions": [ + { + target: "SharedTarget", + inclusions: ["Nested/MyAppApp.swift"], + }, + ], + }); + source = applyXCProjValue(source, ["targets", 1], { + name: "SharedTarget", + id: secondaryTargetId, + "product-type": "application", + "build-phases": ["compile-sources", "frameworks"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, source); + return { primaryTargetId, secondaryTargetId, sharedSourcePath }; +} + /** Converts the classic fixture into the modern synchronized-root shape used by new Xcode apps. */ export async function convertIOSFixtureToSynchronizedRoot(root: string): Promise { const synchronizedRootId = "515151515151515151515151"; diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts index 25147bfff..41dd3ef2d 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -252,6 +252,68 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(diagnostics).toEqual([]); }); + test("resolves logical group-qualified xcconfig references to their physical paths", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-logical-xcconfig-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const projectDocumentPath = join(projectPath, "project.xcproj"); + await mkdir(join(root, "PhysicalConfigs"), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await Bun.write( + join(root, "PhysicalConfigs", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Logical\nDEVELOPMENT_TEAM = LOGICAL123", + ); + const rawTarget: XCProjRecord = { + name: "Example", + id: "TARGET-ID", + "product-type": "application", + "specialized-configurations": [ + { name: "Debug", file: "Build Configurations/Target.xcconfig" }, + { name: "Release", file: "Build Configurations/Target.xcconfig" }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }; + const project: XCProjRecord = { + configurations: ["Debug", "Release"], + files: [ + { + kind: "group", + name: "Build Configurations", + path: "PhysicalConfigs", + children: [{ path: "Target.xcconfig" }], + }, + ], + "build-settings": { SDKROOT: "iphoneos" }, + targets: [rawTarget], + }; + const diagnostics: IOSDiagnostic[] = []; + + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath, + project, + target: xcprojTargets(project)[0]!, + diagnostics, + }); + + expect(configurations).toHaveLength(2); + for (const configuration of configurations) { + expect(configuration.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Logical", + }); + expect(configuration.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "LOGICAL123", + }); + } + expect(diagnostics).toEqual([]); + }); + test("fails string-form xcconfig resolution closed when a filename is ambiguous", async () => { const { configurations, diagnostics } = await inspectFixture( { @@ -264,7 +326,10 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(configurations[0]?.model.bundleIdentifier.state).toBe("unresolved"); expect(diagnostics).toContainEqual( - expect.objectContaining({ code: "xcode.dangling-reference", severity: "error" }), + expect.objectContaining({ + code: "xcode.dangling-reference", + severity: "error", + }), ); }); }); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts index 0d01b6337..6ff56020b 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -1,4 +1,4 @@ -import { basename, dirname, resolve } from "node:path"; +import { basename, dirname, relative, resolve } from "node:path"; import { inspectTargetBuildConfigurations, type InspectedTargetConfiguration, @@ -145,19 +145,33 @@ function configurationFileIndex(projectPath: string, project: XCProjRecord): Map const projectDirectory = dirname(projectPath); const paths = new Map>(); const add = (token: string, path: string): void => { - if (!token) return; - const matches = paths.get(token) ?? new Set(); + const normalizedToken = token.replaceAll("\\", "/").replace(/^\.\//, ""); + if (!normalizedToken) return; + const matches = paths.get(normalizedToken) ?? new Set(); matches.add(path); - paths.set(token, matches); + paths.set(normalizedToken, matches); }; - const visit = (raw: unknown, parent: string): void => { + const logicalChildPath = (parent: string, child: string): string => + parent ? `${parent}/${child}` : child; + const visit = (raw: unknown, parent: string, logicalParent: string): void => { const reference = xcprojRecord(raw); const kind = typeof reference.kind === "string" ? reference.kind : "file"; const path = typeof reference.path === "string" ? reference.path : ""; if (kind === "group") { const groupDirectory = path ? projectReferencePath(projectDirectory, parent, path) : parent; if (!groupDirectory) return; - for (const child of xcprojArray(reference.children ?? [])) visit(child, groupDirectory); + const logicalName = + typeof reference.name === "string" && reference.name + ? reference.name + : path + ? basename(path.replaceAll("\\", "/")) + : ""; + const logicalGroupPath = logicalName + ? logicalChildPath(logicalParent, logicalName) + : logicalParent; + for (const child of xcprojArray(reference.children ?? [])) { + visit(child, groupDirectory, logicalGroupPath); + } return; } if (kind !== "file" || !path) return; @@ -165,9 +179,13 @@ function configurationFileIndex(projectPath: string, project: XCProjRecord): Map if (!absolutePath) return; const displayName = typeof reference.name === "string" ? reference.name : basename(path); add(displayName, absolutePath); - add(path.replaceAll("\\", "/"), absolutePath); + add(path, absolutePath); + add(logicalChildPath(logicalParent, displayName), absolutePath); + add(relative(projectDirectory, absolutePath), absolutePath); }; - for (const reference of xcprojArray(project.files ?? [])) visit(reference, projectDirectory); + for (const reference of xcprojArray(project.files ?? [])) { + visit(reference, projectDirectory, ""); + } return new Map([...paths].map(([token, matches]) => [token, [...matches].sort()] as const)); } diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 2d92ba16f..6afa9419e 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -326,10 +326,14 @@ function folderMembership( } const matchesPath = (set: Set, path: string): boolean => [...set].some((candidate) => path === candidate || path.startsWith(`${candidate}/`)); + const matchesPathOrIncludedDescendant = (set: Set, path: string): boolean => + matchesPath(set, path) || [...set].some((candidate) => candidate.startsWith(`${path}/`)); return { member: defaultMember || inclusions.size > 0, included(path) { - const base = defaultMember ? !matchesPath(exclusions, path) : matchesPath(inclusions, path); + const base = defaultMember + ? !matchesPath(exclusions, path) + : matchesPathOrIncludedDescendant(inclusions, path); if (!base || !platform) return base; for (const [candidate, raw] of filters) { if (path !== candidate && !path.startsWith(`${candidate}/`)) continue; diff --git a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts index 804cf3150..119759101 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts @@ -304,7 +304,11 @@ function productMembers(target: XCProjRecord): ProductMember[] | XCProjSDKInstal } function appliesToPlatform(member: ProductMember, platform: IOSNativePlatform): boolean { - return member.platforms === undefined || member.platforms.includes(platform); + return ( + member.platforms === undefined || + member.platforms.length === 0 || + member.platforms.includes(platform) + ); } function validateProductMembers( From bcc9b7e7a3d5d078a6aeca813d2e65bc5f1aa7ac Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 22:17:44 -0400 Subject: [PATCH 03/10] fix: preserve Xcode JSON ownership guarantees --- .../init/ios/associated-domain.test.ts | 65 +++++++++ .../commands/init/ios/associated-domain.ts | 90 ++++++++++++- .../init/ios/entitlements-settings.test.ts | 80 +++++++++++ .../init/ios/entitlements-settings.ts | 79 ++++++++--- .../commands/init/ios/macos-network.test.ts | 38 ++++++ .../init/ios/xcproj-build-settings.test.ts | 126 ++++++++++++++++++ .../init/ios/xcproj-build-settings.ts | 20 +-- 7 files changed, 472 insertions(+), 26 deletions(-) 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 d7df893aa..e584b0261 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 @@ -20,11 +20,13 @@ import { } from "./associated-domain.ts"; import { convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSJSONFixture, createIOSFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; import type { PbxObjects } from "./pbx.ts"; +import { applyXCProjValue } from "./xcproj.ts"; const temporaryDirectories: string[] = []; const HOST = "direct.clerk.example"; @@ -719,6 +721,69 @@ struct MyApp: App { expect(await treeDigest(root)).toEqual(before); }); + test("blocks a JSON project entitlements file shared by another selected-target platform", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "SUPPORTED_PLATFORMS"], + "iphoneos iphonesimulator macosx", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "MACOSX_DEPLOYMENT_TARGET"], + "14.0", + ); + project = applyXCProjValue(project, ["build-settings", "SDKROOT"], "auto"); + await writeFile(projectPath, project); + + const before = await treeDigest(root); + const plan = await planIOSAssociatedDomain({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + platform: "macos", + deferToPublishableKey: true, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: "shared-entitlements" })); + expect(await treeDigest(root)).toEqual(before); + }); + + test("allows explicit selected-target cross-platform sharing in a JSON project", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "SUPPORTED_PLATFORMS"], + "iphoneos iphonesimulator macosx", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "MACOSX_DEPLOYMENT_TARGET"], + "14.0", + ); + project = applyXCProjValue(project, ["build-settings", "SDKROOT"], "auto"); + await writeFile(projectPath, project); + + const plan = await planIOSAssociatedDomain({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + platform: "macos", + deferToPublishableKey: true, + allowSelectedTargetPlatformSharing: true, + }); + + expect(plan.status).toBe("ready"); + expect(plan.blockers).toEqual([]); + }); + test("returns stale and preserves newer bytes", 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 1c7f49baf..97daa2a20 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -33,6 +33,7 @@ import type { IOSNativePlatform, IOSProjectInspectionResult, } from "./types.ts"; +import { inspectXCProjTargetBuildConfigurations } from "./xcproj-build-settings.ts"; import { parseXCProjSource, xcprojTargets } from "./xcproj.ts"; const ASSOCIATED_DOMAINS_KEY = "com.apple.developer.associated-domains"; @@ -440,7 +441,8 @@ async function ownershipIsExclusive( 15_000_000, ); if (projectFile.status !== "ok") return false; - const targets = xcprojTargets(parseXCProjSource(projectFile.bytes).root); + const project = parseXCProjSource(projectFile.bytes).root; + const targets = xcprojTargets(project); // The canonical JSON project path is safe when it has only the // selected app target. Additional JSON targets are preserved but left // for manual review until their non-application entitlement ownership @@ -452,6 +454,92 @@ async function ownershipIsExclusive( ) { return false; } + + const target = targets[0]; + if (!target) return false; + const primaryDiagnostics: IOSDiagnostic[] = []; + const primaryConfigurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + projectDocumentPath: documentResolution.document.absolutePath, + project, + target, + diagnostics: primaryDiagnostics, + }); + if ( + primaryConfigurations.length === 0 || + primaryConfigurations.some((configuration) => !configuration.platformEvidenceComplete) || + primaryDiagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + if ( + !primaryConfigurations.every( + (configuration) => configuration.platform === primaryConfigurations[0]?.platform, + ) + ) { + return false; + } + + const primaryPlatform = primaryConfigurations[0]?.platform; + const supportedPlatforms = (["ios", "macos"] as const).filter((platform) => + primaryConfigurations.some((configuration) => + configuration.supportedPlatforms.includes(platform), + ), + ); + const views: Array<{ + platform?: IOSNativePlatform; + configurations: typeof primaryConfigurations; + }> = [{ platform: primaryPlatform, configurations: primaryConfigurations }]; + for (const platform of supportedPlatforms) { + if (platform === primaryPlatform) continue; + const diagnostics: IOSDiagnostic[] = []; + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: absoluteProject, + projectDocumentPath: documentResolution.document.absolutePath, + project, + target, + diagnostics, + platform, + }); + if ( + configurations.length !== primaryConfigurations.length || + configurations.some( + (configuration) => + !configuration.platformEvidenceComplete || configuration.platform !== platform, + ) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return false; + } + views.push({ platform, configurations }); + } + + for (const view of views) { + if (view.platform === selectedPlatform || allowSelectedTargetPlatformSharing) { + continue; + } + for (const configuration of view.configurations) { + const resolution = configuration.model.entitlementsPath; + if (resolution.state === "unresolved") return false; + if (resolution.state !== "resolved") continue; + const siblingPath = resolve(dirname(absoluteProject), resolution.value); + if (!(await pathIsSafelyWithinIOSRoot(root, siblingPath))) return false; + try { + const canonical = await realpath(siblingPath); + const info = await lstat(siblingPath); + if ( + selectedCanonical.has(canonical) || + selectedInodes.has(`${info.dev}:${info.ino}`) + ) { + return false; + } + } catch { + // A missing sibling entitlements path cannot currently alias an existing selected file. + } + } + } continue; } const pbxprojPath = resolve(absoluteProject, "project.pbxproj"); 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 40be909d2..4f3094201 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 @@ -182,6 +182,42 @@ async function initializeGitRepository(root: string): Promise { if (exitCode !== 0) throw new Error("Could not initialize the test Git repository."); } +async function makeXCProjMissingEntitlementsWithOtherTarget(xcconfig: string): Promise { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + await mkdir(join(root, "Config"), { recursive: true }); + await mkdir(join(root, "Tests"), { recursive: true }); + await writeFile(join(root, "Config", "Tests.xcconfig"), xcconfig); + + const path = xcprojPath(root); + let source = await readFile(path, "utf8"); + source = applyXCProjValue( + source, + ["targets", 0, "build-settings", "CODE_SIGN_ENTITLEMENTS"], + undefined, + ); + source = applyXCProjValue(source, ["files", 2], { path: "Config/Tests.xcconfig" }); + source = applyXCProjValue(source, ["targets", 1], { + name: "MyAppTests", + id: "C1E000000000000000000099", + "product-type": "unit-test", + "build-phases": ["compile-sources"], + "specialized-configurations": [ + { name: "Debug", file: "Tests.xcconfig" }, + { name: "Release", file: "Tests.xcconfig" }, + ], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.MyAppTests", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(path, source); + await rm(entitlementsPath(root)); + return root; +} + afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), @@ -266,6 +302,50 @@ describe("missing iOS entitlements build settings", () => { expect(await readFile(path)).toEqual(after); }); + test("blocks a missing Xcode JSON destination referenced through another target xcconfig", async () => { + const root = await makeXCProjMissingEntitlementsWithOtherTarget( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements\n", + ); + + const plan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-synchronized-root"); + }); + + test("allows an unrelated effective entitlements path on another Xcode JSON target", async () => { + const root = await makeXCProjMissingEntitlementsWithOtherTarget( + "CODE_SIGN_ENTITLEMENTS = Tests/MyAppTests.entitlements\n", + ); + + expect( + await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }), + ).toMatchObject({ status: "ready", blockers: [] }); + }); + + test("fails closed on an unresolved other-target Xcode JSON xcconfig", async () => { + const root = await makeXCProjMissingEntitlementsWithOtherTarget( + "CODE_SIGN_ENTITLEMENTS = $(TESTS_ENTITLEMENTS_DIR)/MyAppTests.entitlements\n", + ); + + const plan = await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-synchronized-root"); + }); + test("composes Xcode JSON SDK and entitlements edits into one project mutation", async () => { const root = await temporaryRoot(); await createIOSJSONFixture(root); 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 79782532f..68c1c0f80 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -820,12 +820,59 @@ function pathContains(directory: string, candidate: string): boolean { ); } -function entitlementsSettingEntries( - settings: Readonly>, -): Array<[string, unknown]> { - return Object.entries(settings).filter(([key]) => - /^CODE_SIGN_ENTITLEMENTS(?:\[.*\])?$/.test(key), - ); +async function xcprojTargetEntitlementsConfigurations( + root: string, + snapshot: XCProjProjectSnapshot, + target: XCProjTarget, +): Promise> | undefined> { + const inspect = async ( + platform?: IOSNativePlatform, + ): Promise> | undefined> => { + const diagnostics: IOSDiagnostic[] = []; + let configurations: Awaited>; + try { + configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath: snapshot.absoluteProjectPath, + projectDocumentPath: snapshot.documentPath, + project: snapshot.document, + target, + diagnostics, + platform, + }); + } catch { + return undefined; + } + if ( + configurations.length !== snapshot.configurationIds.length || + configurations.length === 0 || + configurations.some( + (configuration) => + !configuration.platformEvidenceComplete || + (platform !== undefined && configuration.platform !== platform) || + configuration.model.entitlementsPath.state === "unresolved", + ) || + diagnostics.some((diagnostic) => diagnostic.severity === "error") + ) { + return undefined; + } + return configurations; + }; + + const defaultView = await inspect(); + if (!defaultView) return undefined; + const platforms = [ + ...new Set(defaultView.flatMap((configuration) => configuration.supportedPlatforms)), + ]; + if (platforms.length === 0) return defaultView; + + const configurations: Awaited> = []; + for (const platform of platforms) { + const platformView = await inspect(platform); + if (!platformView) return undefined; + configurations.push(...platformView); + } + return configurations; } async function xcprojDestinationOwnershipIsExclusive( @@ -857,19 +904,17 @@ async function xcprojDestinationOwnershipIsExclusive( return false; } - const projectSettings = snapshot.document["build-settings"]; - if (projectSettings !== undefined) { - try { - if (entitlementsSettingEntries(xcprojRecord(projectSettings)).length > 0) return false; - } catch { - return false; - } - } for (const target of xcprojTargets(snapshot.document)) { if (target.id === snapshot.target.id) continue; - for (const [, rawValue] of entitlementsSettingEntries(target.buildSettings)) { - if (typeof rawValue !== "string" || rawValue.includes("$(")) return false; - const targetPath = resolve(dirname(snapshot.absoluteProjectPath), rawValue); + const configurations = await xcprojTargetEntitlementsConfigurations(root, snapshot, target); + if (!configurations) return false; + for (const configuration of configurations) { + const entitlementsPath = configuration.model.entitlementsPath; + if (entitlementsPath.state === "missing") continue; + if (entitlementsPath.state !== "resolved") return false; + const value = entitlementsPath.value.trim(); + if (!value) continue; + const targetPath = resolve(dirname(snapshot.absoluteProjectPath), value); if (!(await pathIsSafelyWithinIOSRoot(root, targetPath))) return false; try { if ( diff --git a/packages/cli-core/src/commands/init/ios/macos-network.test.ts b/packages/cli-core/src/commands/init/ios/macos-network.test.ts index a8b517f06..0db3b922c 100644 --- a/packages/cli-core/src/commands/init/ios/macos-network.test.ts +++ b/packages/cli-core/src/commands/init/ios/macos-network.test.ts @@ -18,10 +18,12 @@ import { import type { PbxObjects } from "./pbx.ts"; import { convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSJSONFixture, createIOSFixture, IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; +import { applyXCProjValue } from "./xcproj.ts"; const temporaryDirectories: string[] = []; @@ -237,6 +239,42 @@ describe("macOS outgoing network capability", () => { }); }); + test("blocks mutating a JSON project entitlement shared by iOS and macOS builds", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-network-json-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(path, "utf8"); + project = applyXCProjValue(project, ["build-settings", "SDKROOT"], "auto"); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "SUPPORTED_PLATFORMS"], + "iphoneos iphonesimulator macosx", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "MACOSX_DEPLOYMENT_TARGET"], + "14.0", + ); + project = applyXCProjValue( + project, + ["targets", 0, "build-settings", "ENABLE_APP_SANDBOX"], + "YES", + ); + await writeFile(path, project); + + await expect( + planMacOSNetworkCapability({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }), + ).resolves.toMatchObject({ + status: "blocked", + blockers: [{ code: "unsafe-entitlements" }], + }); + }); + test("blocks a macOS entitlement aliased by a sibling multiplatform target", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-macos-network-sibling-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts index 41dd3ef2d..42e71a674 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -165,6 +165,13 @@ describe("inspectXCProjTargetBuildConfigurations", () => { file: { anchor: "Config", "relative-path": "Project.xcconfig" }, }, ], + files: [ + { + kind: "group", + path: "Config", + children: [{ path: "Project.xcconfig" }, { path: "Target.xcconfig" }], + }, + ], "build-settings": { SDKROOT: "iphoneos" }, targets: [rawTarget], }; @@ -190,6 +197,125 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(diagnostics).toEqual([]); }); + test("resolves object-form xcconfig anchors through logical groups instead of physical paths", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-object-xcconfig-")); + temporaryDirectories.push(root); + const projectPath = join(root, "Example.xcodeproj"); + const projectDocumentPath = join(projectPath, "project.xcproj"); + await mkdir(join(root, "PhysicalSources"), { recursive: true }); + await mkdir(join(root, "LogicalSources"), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await Bun.write( + join(root, "PhysicalSources", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Correct\nDEVELOPMENT_TEAM = CORRECT123", + ); + await Bun.write( + join(root, "LogicalSources", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Wrong\nDEVELOPMENT_TEAM = WRONG12345", + ); + const rawTarget: XCProjRecord = { + name: "Example", + id: "TARGET-ID", + "product-type": "application", + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "LogicalSources", "relative-path": "Target.xcconfig" }, + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }; + const project: XCProjRecord = { + configurations: ["Debug"], + files: [ + { + kind: "group", + name: "LogicalSources", + path: "PhysicalSources", + children: [{ path: "Target.xcconfig" }], + }, + ], + "build-settings": { SDKROOT: "iphoneos" }, + targets: [rawTarget], + }; + const diagnostics: IOSDiagnostic[] = []; + + const configurations = await inspectXCProjTargetBuildConfigurations({ + root, + projectPath, + projectDocumentPath, + project, + target: xcprojTargets(project)[0]!, + diagnostics, + }); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Correct", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "CORRECT123", + }); + expect(diagnostics).toEqual([]); + }); + + test("fails object-form xcconfig anchors closed when the logical path is ambiguous", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + files: [ + { + kind: "group", + name: "LogicalSources", + path: "ConfigA", + children: [{ path: "Target.xcconfig" }], + }, + { + kind: "group", + name: "LogicalSources", + path: "ConfigB", + children: [{ path: "Target.xcconfig" }], + }, + ], + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "LogicalSources", "relative-path": "Target.xcconfig" }, + }, + ], + }, + ); + + expect(configurations[0]?.model.bundleIdentifier.state).toBe("unresolved"); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.dangling-reference", severity: "error" }), + ); + }); + + test("fails object-form xcconfig anchors closed when the logical path is missing", async () => { + const { configurations, diagnostics } = await inspectFixture( + { files: [{ path: "Config/Other.xcconfig" }] }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "Config", "relative-path": "Target.xcconfig" }, + }, + ], + }, + ); + + expect(configurations[0]?.model.bundleIdentifier.state).toBe("unresolved"); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.dangling-reference", severity: "error" }), + ); + }); + test("resolves Xcode 27 string-form xcconfig references through the file graph", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-string-xcconfig-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts index 6ff56020b..09f3cb084 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -141,11 +141,15 @@ function projectReferencePath( return resolve(parent, path); } +function normalizeConfigurationReferenceToken(token: string): string { + return token.replaceAll("\\", "/").replace(/^\.\//, ""); +} + function configurationFileIndex(projectPath: string, project: XCProjRecord): Map { const projectDirectory = dirname(projectPath); const paths = new Map>(); const add = (token: string, path: string): void => { - const normalizedToken = token.replaceAll("\\", "/").replace(/^\.\//, ""); + const normalizedToken = normalizeConfigurationReferenceToken(token); if (!normalizedToken) return; const matches = paths.get(normalizedToken) ?? new Set(); matches.add(path); @@ -190,32 +194,34 @@ function configurationFileIndex(projectPath: string, project: XCProjRecord): Map } function configurationFilePath( - projectPath: string, file: string | XCProjRecord | undefined, indexedFiles: ReadonlyMap, ): string | undefined { if (!file) return undefined; if (typeof file === "string") { - const matches = indexedFiles.get(file.replaceAll("\\", "/")) ?? []; + const matches = indexedFiles.get(normalizeConfigurationReferenceToken(file)) ?? []; return matches.length === 1 ? matches[0] : undefined; } const anchor = simpleNamePath(file.anchor); const relativePath = simpleNamePath(file["relative-path"]); if (!anchor || anchor.startsWith("id:") || relativePath === undefined) return undefined; - return resolve(dirname(projectPath), anchor, relativePath); + const token = normalizeConfigurationReferenceToken( + relativePath ? `${anchor}/${relativePath}` : anchor, + ); + const matches = indexedFiles.get(token) ?? []; + return matches.length === 1 ? matches[0] : undefined; } function attachBaseConfiguration( objects: PbxObjects, configurationObject: PbxObject, - projectPath: string, file: string | XCProjRecord | undefined, indexedFiles: ReadonlyMap, referenceId: string, ): void { if (!file) return; configurationObject.baseConfigurationReference = referenceId; - const path = configurationFilePath(projectPath, file, indexedFiles); + const path = configurationFilePath(file, indexedFiles); if (!path) return; objects[referenceId] = { isa: "PBXFileReference", @@ -264,7 +270,6 @@ export async function inspectXCProjTargetBuildConfigurations( attachBaseConfiguration( objects, projectConfigurationObject, - options.projectPath, projectConfiguration.file, indexedFiles, projectBaseReferenceId, @@ -280,7 +285,6 @@ export async function inspectXCProjTargetBuildConfigurations( attachBaseConfiguration( objects, targetConfigurationObject, - options.projectPath, targetSpecialization?.file, indexedFiles, targetBaseReferenceId, From 83c103bd4a806ebebfa4eeff688541dee27bf80c Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 23:21:40 -0400 Subject: [PATCH 04/10] fix: resolve Xcode JSON project references --- .../commands/init/ios/direct-config.test.ts | 92 ++++++++ .../src/commands/init/ios/inspect.test.ts | 41 ++++ .../init/ios/xcproj-build-settings.test.ts | 162 ++++++++++++++ .../init/ios/xcproj-build-settings.ts | 211 +++++++++++++----- .../src/commands/init/ios/xcproj-inspect.ts | 144 ++++++++++-- 5 files changed, 572 insertions(+), 78 deletions(-) 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 7fb71d895..fef6826e5 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 @@ -31,6 +31,7 @@ import { IOS_FIXTURE_IDS, treeDigest, } from "./test-helpers.ts"; +import { applyXCProjValue } from "./xcproj.ts"; const DEVELOPMENT_KEY = `pk_test_${Buffer.from("direct-config.clerk.accounts.dev$").toString("base64")}`; const OTHER_DEVELOPMENT_KEY = `pk_test_${Buffer.from("other-app.clerk.accounts.dev$").toString("base64")}`; @@ -792,6 +793,97 @@ struct MyApp: App { expect(await readFile(absoluteSharedSourcePath)).toEqual(before); }); + test.each([ + ["named", "SharedTarget/compile-sources/Shared Sources"], + ["ID-based", "id:SHARED-SOURCES-PHASE"], + ["component-array", ["SharedTarget", "compile-sources", { name: "Shared/Sources" }]], + ] as const)( + "refuses an entry source shared through a %s JSON source-phase reference", + async (_description, phaseReference) => { + const root = await temporaryRoot("clerk-xcproj-direct-config-phase-reference-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue(project, ["files", 2], { + path: "/MyApp/MyAppApp.swift", + "target-membership": [ + Array.isArray(phaseReference) ? { "build-phase": phaseReference } : phaseReference, + ], + }); + project = applyXCProjValue(project, ["targets", 1], { + name: "SharedTarget", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": [ + { + kind: "compile-sources", + name: Array.isArray(phaseReference) ? "Shared/Sources" : "Shared Sources", + id: "SHARED-SOURCES-PHASE", + }, + ], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, project); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }, + ); + + test("refuses an entry source added to another target by a JSON folder build-phase exception", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-phase-exception-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["files", 0, "membership-exceptions"], + [ + { + "build-phase": "SharedTarget/compile-sources", + inclusions: ["MyAppApp.swift"], + }, + ], + ); + project = applyXCProjValue(project, ["targets", 1], { + name: "SharedTarget", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, project); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test.each(["build-phases", "source-phase-files"] as const)( "refuses mutation when shared-source ownership uses malformed %s", async (collection) => { 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 566c4c5c4..af83a6731 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -942,6 +942,47 @@ describe("inspectIOSProject", () => { expect(owners.every((membership) => membership.complete)).toBe(true); }); + test("accepts attribute-only JSON folder exceptions and applies their platform filters", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-attribute-exception-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const source = await readFile(projectPath, "utf8"); + await Bun.write( + projectPath, + applyXCProjValue( + source, + ["files", 0, "membership-exceptions"], + [ + { + target: "MyApp", + platforms: { "ContentView.swift": ["macos"] }, + attributes: { "ContentView.swift": { "code-generation": "skip" } }, + }, + ], + ), + ); + + const inspection = await inspectIOSProject(root); + const membership = (await inspectIOSSourceMembership(root)).find( + (candidate) => candidate.targetId === "C1E000000000000000000001", + ); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 1, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + }); + expect(membership).toMatchObject({ + complete: true, + }); + expect(membership?.files.map((file) => file.relativePath)).toEqual([ + "MyApp/ContentView.swift", + "MyApp/MyAppApp.swift", + ]); + expect(inspection.diagnostics).toEqual([]); + }); + test("extracts the App ID Prefix when Bundle ID casing differs", async () => { const root = await fixture({ complete: true }); const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts index 42e71a674..c565c51c6 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -15,6 +15,7 @@ afterEach(async () => { async function inspectFixture( projectOverrides: XCProjRecord = {}, targetOverrides: XCProjRecord = {}, + setup?: (root: string) => Promise, ) { const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-build-settings-")); temporaryDirectories.push(root); @@ -44,6 +45,7 @@ async function inspectFixture( ...projectOverrides, }; const diagnostics: IOSDiagnostic[] = []; + await setup?.(root); const configurations = await inspectXCProjTargetBuildConfigurations({ root, projectPath, @@ -263,6 +265,166 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(diagnostics).toEqual([]); }); + test("resolves an xcconfig below a top-level synchronized folder without an explicit file leaf", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + configurations: ["Debug"], + files: [{ kind: "folder", path: "Config" }], + "build-settings": { SDKROOT: "iphoneos" }, + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "Config", "relative-path": "Target.xcconfig" }, + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + async (root) => { + await mkdir(join(root, "Config"), { recursive: true }); + await Bun.write( + join(root, "Config", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Folder\nDEVELOPMENT_TEAM = FOLDER1234", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Folder", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "FOLDER1234", + }); + expect(diagnostics).toEqual([]); + }); + + test("resolves a nested folder anchor through component-safe logical names and physical paths", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + configurations: ["Debug"], + files: [ + { + kind: "group", + name: "Build/Settings", + path: "PhysicalRoot", + children: [{ kind: "folder", path: "PhysicalConfigs" }], + }, + ], + "build-settings": { SDKROOT: "iphoneos" }, + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { + anchor: [{ name: "Build/Settings" }, "PhysicalConfigs"], + "relative-path": "Target.xcconfig", + }, + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + async (root) => { + await mkdir(join(root, "PhysicalRoot", "PhysicalConfigs"), { recursive: true }); + await Bun.write( + join(root, "PhysicalRoot", "PhysicalConfigs", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Nested\nDEVELOPMENT_TEAM = NESTED1234", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Nested", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "NESTED1234", + }); + expect(diagnostics).toEqual([]); + }); + + test("resolves an xcconfig relative to a synchronized folder object ID", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + configurations: ["Debug"], + files: [{ kind: "folder", id: "CONFIG-FOLDER", path: "PhysicalConfig" }], + "build-settings": { SDKROOT: "iphoneos" }, + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { anchor: "id:CONFIG-FOLDER", "relative-path": "Target.xcconfig" }, + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + async (root) => { + await mkdir(join(root, "PhysicalConfig"), { recursive: true }); + await Bun.write( + join(root, "PhysicalConfig", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.ByID\nDEVELOPMENT_TEAM = IDANCHOR12", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.ByID", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "IDANCHOR12", + }); + expect(diagnostics).toEqual([]); + }); + + test("fails synchronized-folder anchors closed when the name is ambiguous or the ID is missing", async () => { + for (const { files, anchor } of [ + { + files: [ + { kind: "folder", path: "One/Config" }, + { kind: "folder", path: "Two/Config" }, + ], + anchor: "Config", + }, + { + files: [{ kind: "folder", id: "KNOWN-FOLDER", path: "Config" }], + anchor: "id:MISSING-FOLDER", + }, + ]) { + const { configurations, diagnostics } = await inspectFixture( + { files }, + { + "specialized-configurations": [ + { + name: "Debug", + file: { anchor, "relative-path": "Target.xcconfig" }, + }, + ], + }, + ); + + expect(configurations[0]?.model.bundleIdentifier.state).toBe("unresolved"); + expect(diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.dangling-reference", severity: "error" }), + ); + } + }); + test("fails object-form xcconfig anchors closed when the logical path is ambiguous", async () => { const { configurations, diagnostics } = await inspectFixture( { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts index 09f3cb084..c621bcc5a 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -100,13 +100,29 @@ function configurations(value: unknown, requireAtLeastOne = true): XCProjConfigu return parsed; } -function simpleNamePath(value: unknown): string | undefined { - if (typeof value === "string") return value; - if (!Array.isArray(value)) return undefined; - const components: string[] = []; - for (const component of value) { +type NamePathComponent = { kind: "child"; name: string } | { kind: "relative"; value: "." | ".." }; + +interface IndexedProjectReference { + path: string; +} + +interface ConfigurationReferenceIndex { + files: ReadonlyMap; + referencesById: ReadonlyMap; + referencesByNamePath: ReadonlyMap; +} + +function namePathComponents(value: unknown): NamePathComponent[] | undefined { + const rawComponents = typeof value === "string" ? value.split("/") : value; + if (!Array.isArray(rawComponents)) return undefined; + const components: NamePathComponent[] = []; + for (const component of rawComponents) { if (typeof component === "string") { - components.push(component); + components.push( + component === "." || component === ".." + ? { kind: "relative", value: component } + : { kind: "child", name: component }, + ); continue; } if ( @@ -115,19 +131,26 @@ function simpleNamePath(value: unknown): string | undefined { !Array.isArray(component) && typeof (component as XCProjRecord).name === "string" ) { - components.push((component as XCProjRecord).name as string); + components.push({ kind: "child", name: (component as XCProjRecord).name as string }); continue; } return undefined; } - return components.join("/"); + return components; +} + +function fileSystemNamePath(value: unknown): string | undefined { + const components = namePathComponents(value); + if (!components) return undefined; + return components + .map((component) => (component.kind === "child" ? component.name : component.value)) + .join("/"); +} + +function namePathKey(components: readonly string[]): string { + return JSON.stringify(components); } -/** - * Resolve the compact, path-based form emitted for ordinary checked-in - * xcconfig files. Object-ID anchors require the full groups-and-files graph; - * leave those unresolved so the shared evaluator fails closed. - */ function projectReferencePath( projectDirectory: string, parent: string, @@ -145,83 +168,155 @@ function normalizeConfigurationReferenceToken(token: string): string { return token.replaceAll("\\", "/").replace(/^\.\//, ""); } -function configurationFileIndex(projectPath: string, project: XCProjRecord): Map { +function configurationReferenceIndex( + projectPath: string, + project: XCProjRecord, +): ConfigurationReferenceIndex { const projectDirectory = dirname(projectPath); - const paths = new Map>(); - const add = (token: string, path: string): void => { - const normalizedToken = normalizeConfigurationReferenceToken(token); - if (!normalizedToken) return; - const matches = paths.get(normalizedToken) ?? new Set(); - matches.add(path); - paths.set(normalizedToken, matches); + const files = new Map(); + const referencesById = new Map(); + const referencesByNamePath = new Map(); + const addFile = (tokens: readonly string[], path: string): void => { + const normalizedTokens = new Set( + tokens.map(normalizeConfigurationReferenceToken).filter(Boolean), + ); + for (const normalizedToken of normalizedTokens) { + const matches = files.get(normalizedToken) ?? []; + matches.push(path); + files.set(normalizedToken, matches); + } }; - const logicalChildPath = (parent: string, child: string): string => - parent ? `${parent}/${child}` : child; - const visit = (raw: unknown, parent: string, logicalParent: string): void => { + const addReference = ( + logicalPath: readonly string[], + path: string, + id: unknown, + hasLogicalName: boolean, + ): void => { + const indexed = { path }; + if (hasLogicalName) { + const key = namePathKey(logicalPath); + const namedMatches = referencesByNamePath.get(key) ?? []; + namedMatches.push(indexed); + referencesByNamePath.set(key, namedMatches); + } + if (typeof id === "string" && id) { + const idMatches = referencesById.get(id) ?? []; + idMatches.push(indexed); + referencesById.set(id, idMatches); + } + }; + const logicalChildPath = (parent: readonly string[], child: string): string[] => [ + ...parent, + child, + ]; + const visit = (raw: unknown, parent: string, logicalParent: readonly string[]): void => { const reference = xcprojRecord(raw); - const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const kind = typeof reference.kind === "string" ? reference.kind : "file-reference"; const path = typeof reference.path === "string" ? reference.path : ""; + const logicalName = + typeof reference.name === "string" && reference.name + ? reference.name + : path + ? basename(path.replaceAll("\\", "/")) + : ""; + const logicalPath = logicalName + ? logicalChildPath(logicalParent, logicalName) + : [...logicalParent]; + if (kind === "group") { const groupDirectory = path ? projectReferencePath(projectDirectory, parent, path) : parent; if (!groupDirectory) return; - const logicalName = - typeof reference.name === "string" && reference.name - ? reference.name - : path - ? basename(path.replaceAll("\\", "/")) - : ""; - const logicalGroupPath = logicalName - ? logicalChildPath(logicalParent, logicalName) - : logicalParent; + addReference(logicalPath, groupDirectory, reference.id, Boolean(logicalName)); for (const child of xcprojArray(reference.children ?? [])) { - visit(child, groupDirectory, logicalGroupPath); + visit(child, groupDirectory, logicalPath); } return; } - if (kind !== "file" || !path) return; + + if (kind === "folder") { + const folderDirectory = path ? projectReferencePath(projectDirectory, parent, path) : parent; + if (!folderDirectory) return; + addReference(logicalPath, folderDirectory, reference.id, Boolean(logicalName)); + return; + } + + if ((kind !== "file" && kind !== "file-reference") || !path) return; const absolutePath = projectReferencePath(projectDirectory, parent, path); if (!absolutePath) return; - const displayName = typeof reference.name === "string" ? reference.name : basename(path); - add(displayName, absolutePath); - add(path, absolutePath); - add(logicalChildPath(logicalParent, displayName), absolutePath); - add(relative(projectDirectory, absolutePath), absolutePath); + addReference(logicalPath, absolutePath, reference.id, Boolean(logicalName)); + const logicalToken = logicalPath.join("/"); + addFile( + [logicalName, path, logicalToken, relative(projectDirectory, absolutePath)], + absolutePath, + ); }; for (const reference of xcprojArray(project.files ?? [])) { - visit(reference, projectDirectory, ""); + visit(reference, projectDirectory, []); } - return new Map([...paths].map(([token, matches]) => [token, [...matches].sort()] as const)); + const sorted = (matches: string[]): string[] => [...matches].sort(); + return { + files: new Map([...files].map(([token, matches]) => [token, sorted(matches)] as const)), + referencesById, + referencesByNamePath, + }; +} + +function anchoredReferencePath( + anchor: unknown, + index: ConfigurationReferenceIndex, +): string | undefined { + if (typeof anchor === "string" && anchor.startsWith("id:")) { + const matches = index.referencesById.get(anchor.slice("id:".length)) ?? []; + return matches.length === 1 ? matches[0]?.path : undefined; + } + + const components = namePathComponents(anchor); + if (!components) return undefined; + const logicalPath: string[] = []; + let match: IndexedProjectReference | undefined; + for (const component of components) { + if (component.kind === "child") { + logicalPath.push(component.name); + } else if (component.value === "..") { + if (logicalPath.length === 0) return undefined; + logicalPath.pop(); + } + if (logicalPath.length === 0) { + match = undefined; + continue; + } + const matches = index.referencesByNamePath.get(namePathKey(logicalPath)) ?? []; + if (matches.length !== 1) return undefined; + match = matches[0]; + } + return match?.path; } function configurationFilePath( file: string | XCProjRecord | undefined, - indexedFiles: ReadonlyMap, + index: ConfigurationReferenceIndex, ): string | undefined { if (!file) return undefined; if (typeof file === "string") { - const matches = indexedFiles.get(normalizeConfigurationReferenceToken(file)) ?? []; + const matches = index.files.get(normalizeConfigurationReferenceToken(file)) ?? []; return matches.length === 1 ? matches[0] : undefined; } - const anchor = simpleNamePath(file.anchor); - const relativePath = simpleNamePath(file["relative-path"]); - if (!anchor || anchor.startsWith("id:") || relativePath === undefined) return undefined; - const token = normalizeConfigurationReferenceToken( - relativePath ? `${anchor}/${relativePath}` : anchor, - ); - const matches = indexedFiles.get(token) ?? []; - return matches.length === 1 ? matches[0] : undefined; + const anchorPath = anchoredReferencePath(file.anchor, index); + const relativePath = fileSystemNamePath(file["relative-path"]); + if (!anchorPath || relativePath === undefined) return undefined; + return resolve(anchorPath, relativePath); } function attachBaseConfiguration( objects: PbxObjects, configurationObject: PbxObject, file: string | XCProjRecord | undefined, - indexedFiles: ReadonlyMap, + index: ConfigurationReferenceIndex, referenceId: string, ): void { if (!file) return; configurationObject.baseConfigurationReference = referenceId; - const path = configurationFilePath(file, indexedFiles); + const path = configurationFilePath(file, index); if (!path) return; objects[referenceId] = { isa: "PBXFileReference", @@ -253,7 +348,7 @@ export async function inspectXCProjTargetBuildConfigurations( const projectConfigurationIds: string[] = []; const targetConfigurationIds: string[] = []; const projectSettings = buildSettings(project["build-settings"]); - const indexedFiles = configurationFileIndex(options.projectPath, project); + const referenceIndex = configurationReferenceIndex(options.projectPath, project); for (const [index, projectConfiguration] of projectConfigurations.entries()) { const projectConfigurationId = `__xcproj_project_configuration_${index}`; @@ -271,7 +366,7 @@ export async function inspectXCProjTargetBuildConfigurations( objects, projectConfigurationObject, projectConfiguration.file, - indexedFiles, + referenceIndex, projectBaseReferenceId, ); objects[projectConfigurationId] = projectConfigurationObject; @@ -286,7 +381,7 @@ export async function inspectXCProjTargetBuildConfigurations( objects, targetConfigurationObject, targetSpecialization?.file, - indexedFiles, + referenceIndex, targetBaseReferenceId, ); objects[targetConfigurationId] = targetConfigurationObject; diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 6afa9419e..0074e4ba6 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -19,6 +19,7 @@ import type { } from "./types.ts"; import { XCProjError, + type XCProjBuildPhaseKind, type XCProjRecord, type XCProjTarget, xcprojArray, @@ -212,6 +213,79 @@ function normalizedPath(path: string): string { return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""); } +interface ResolvedProjectBuildPhase { + targetId: string; + targetName: string; + kind: XCProjBuildPhaseKind; +} + +type ProjectBuildPhaseResolver = (value: unknown) => ResolvedProjectBuildPhase | undefined; + +function buildPhaseNamePath(value: unknown): string[] | undefined { + if (typeof value === "string") return value.split("/"); + if (!Array.isArray(value)) return undefined; + const components: string[] = []; + for (const raw of value) { + if (typeof raw === "string") { + // A literal child named "." or ".." is encoded as { name }, while a + // bare string has relative-path semantics and is invalid in this reference. + if (raw === "." || raw === "..") return undefined; + components.push(raw); + continue; + } + if ( + typeof raw !== "object" || + raw === null || + Array.isArray(raw) || + typeof (raw as XCProjRecord).name !== "string" + ) { + return undefined; + } + components.push((raw as XCProjRecord).name as string); + } + return components; +} + +function projectBuildPhaseResolver(document: XCProjRecord): ProjectBuildPhaseResolver { + const targets = xcprojTargets(document); + return (value) => { + if (typeof value === "string" && value.startsWith("id:")) { + const objectId = value.slice("id:".length); + if (!objectId) return undefined; + const matches = targets.flatMap((target) => + target.buildPhases + .filter((phase) => phase.id === objectId) + .map((phase) => ({ + targetId: target.id, + targetName: target.name, + kind: phase.kind, + })), + ); + return matches.length === 1 ? matches[0] : undefined; + } + + const components = buildPhaseNamePath(value); + if (!components || (components.length !== 2 && components.length !== 3)) return undefined; + const [targetName, kind, phaseName] = components; + const matches = targets.flatMap((target) => + target.name === targetName + ? target.buildPhases + .filter( + (phase) => + phase.kind === kind && + (phaseName === undefined ? phase.name === undefined : phase.name === phaseName), + ) + .map((phase) => ({ + targetId: target.id, + targetName: target.name, + kind: phase.kind, + })) + : [], + ); + return matches.length === 1 ? matches[0] : undefined; + }; +} + function sourceReferencePath( projectDirectory: string, parent: string, @@ -272,7 +346,8 @@ async function collectSwiftFiles( function folderMembership( reference: XCProjRecord, - targetName: string, + target: XCProjTarget, + resolveBuildPhase: ProjectBuildPhaseResolver, platform: IOSNativePlatform | undefined, state: { complete: boolean }, ): { member: boolean; included: (path: string) => boolean } { @@ -286,7 +361,7 @@ function folderMembership( state.complete = false; members = []; } - const defaultMember = members.includes(targetName); + const defaultMember = members.includes(target.name); const inclusions = new Set(); const exclusions = new Set(); const filters = new Map(); @@ -303,17 +378,39 @@ function folderMembership( let exception: XCProjRecord; try { exception = xcprojRecord(raw); - if (exception.target !== targetName) continue; + const hasTarget = Object.hasOwn(exception, "target"); + const hasBuildPhase = Object.hasOwn(exception, "build-phase"); + if (hasTarget === hasBuildPhase) { + state.complete = false; + continue; + } + + let buildPhaseException = false; + if (hasTarget) { + if (xcprojString(exception.target) !== target.name) continue; + } else { + const phase = resolveBuildPhase(exception["build-phase"]); + if (!phase) { + state.complete = false; + continue; + } + if (phase.targetId !== target.id) continue; + if (phase.kind !== "compile-sources") continue; + buildPhaseException = true; + } + const hasInclusions = Object.hasOwn(exception, "inclusions"); const hasExclusions = Object.hasOwn(exception, "exclusions"); - if (hasInclusions === hasExclusions) { + if ((hasInclusions && hasExclusions) || (buildPhaseException && hasExclusions)) { state.complete = false; continue; } - for (const path of xcprojStringArray( - exception[hasInclusions ? "inclusions" : "exclusions"], - )) { - (hasInclusions ? inclusions : exclusions).add(normalizedPath(path)); + if (hasInclusions || hasExclusions) { + for (const path of xcprojStringArray( + exception[hasInclusions ? "inclusions" : "exclusions"], + )) { + (hasInclusions ? inclusions : exclusions).add(normalizedPath(path)); + } } if (exception.platforms !== undefined) { const byPath = xcprojRecord(exception.platforms); @@ -348,7 +445,8 @@ function folderMembership( function fileBelongsToSources( reference: XCProjRecord, - targetName: string, + target: XCProjTarget, + resolveBuildPhase: ProjectBuildPhaseResolver, platform: IOSNativePlatform | undefined, state: { complete: boolean }, ): boolean { @@ -363,20 +461,25 @@ function fileBelongsToSources( return false; } for (const raw of memberships) { - let buildPhase: string | undefined; + let buildPhase: unknown; let filters: unknown; if (typeof raw === "string") buildPhase = raw; else { try { const member = xcprojRecord(raw); - buildPhase = xcprojString(member["build-phase"]); + buildPhase = member["build-phase"]; filters = member.platforms; } catch { state.complete = false; continue; } } - if (buildPhase !== `${targetName}/compile-sources`) continue; + const phase = resolveBuildPhase(buildPhase); + if (!phase) { + state.complete = false; + continue; + } + if (phase.targetId !== target.id || phase.kind !== "compile-sources") continue; if (!platform) return true; const result = platformFiltersApply(filters, platform); state.complete &&= result.complete; @@ -389,14 +492,15 @@ async function sourceFilesForTarget(options: { root: string; projectPath: string; document: XCProjRecord; - targetName: string; + target: XCProjTarget; platform?: IOSNativePlatform; diagnostics: IOSDiagnostic[]; }): Promise<{ files: Array<{ absolutePath: string; relativePath: string }>; complete: boolean }> { - const { root, projectPath, document, targetName, platform, diagnostics } = options; + const { root, projectPath, document, target, platform, diagnostics } = options; const state = { complete: true }; const files = new Map(); const projectDirectory = dirname(projectPath); + const resolveBuildPhase = projectBuildPhaseResolver(document); const visit = async (raw: unknown, parent: string): Promise => { let reference: XCProjRecord; try { @@ -427,7 +531,7 @@ async function sourceFilesForTarget(options: { } const directory = sourceReferencePath(projectDirectory, parent, path); if (!directory) return; - const membership = folderMembership(reference, targetName, platform, state); + const membership = folderMembership(reference, target, resolveBuildPhase, platform, state); if (membership.member) { await collectSwiftFiles(root, directory, directory, membership.included, files, state); } @@ -438,7 +542,7 @@ async function sourceFilesForTarget(options: { return; } if (!path || extname(path) !== ".swift") return; - if (!fileBelongsToSources(reference, targetName, platform, state)) return; + if (!fileBelongsToSources(reference, target, resolveBuildPhase, platform, state)) return; const absolutePath = sourceReferencePath(projectDirectory, parent, path); if (!absolutePath || !(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { state.complete = false; @@ -457,8 +561,8 @@ async function sourceFilesForTarget(options: { severity: "info", message: files.size === 0 - ? `No Swift source membership could be resolved for ${targetName}; source-level Clerk checks may be incomplete.` - : `Swift source membership for ${targetName} was only partially inspected; absence checks are advisory.`, + ? `No Swift source membership could be resolved for ${target.name}; source-level Clerk checks may be incomplete.` + : `Swift source membership for ${target.name} was only partially inspected; absence checks are advisory.`, evidence: [{ path: relativeIOSPath(root, resolve(projectPath, "project.xcproj")) }], }); } @@ -494,7 +598,7 @@ export async function inspectXCProjProject(options: { root, projectPath, document, - targetName: target.name, + target, diagnostics: [], }); sourceMemberships.push({ @@ -617,7 +721,7 @@ export async function inspectXCProjProject(options: { root, projectPath, document, - targetName: target.name, + target, platform: targetPlatform, diagnostics: targetSourceDiagnostics, }); From d74b1f0a5b81c37ba95be5033e336b5d7b1f942e Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 21 Sep 2026 23:41:25 -0400 Subject: [PATCH 05/10] fix: complete Xcode JSON path resolution --- .../commands/init/ios/direct-config.test.ts | 54 +++++++++++++++++++ .../init/ios/xcproj-build-settings.test.ts | 39 ++++++++++++++ .../init/ios/xcproj-build-settings.ts | 1 + .../src/commands/init/ios/xcproj-inspect.ts | 10 +++- 4 files changed, 102 insertions(+), 2 deletions(-) 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 fef6826e5..a524f3223 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 @@ -884,6 +884,60 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test.each(["folder", "group"] as const)( + "refuses mutation when another target's JSON %s uses an unresolved source-root path", + async (kind) => { + const root = await temporaryRoot("clerk-xcproj-direct-config-unresolved-source-root-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["files", 2], + kind === "folder" + ? { + kind, + path: "/MyApp", + "target-membership": ["SharedTarget"], + } + : { + kind, + path: "/MyApp", + children: [ + { + path: "MyAppApp.swift", + "target-membership": ["SharedTarget/compile-sources"], + }, + ], + }, + ); + project = applyXCProjValue(project, ["targets", 1], { + name: "SharedTarget", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, project); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }, + ); + test.each(["build-phases", "source-phase-files"] as const)( "refuses mutation when shared-source ownership uses malformed %s", async (collection) => { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts index c565c51c6..d36bca9ec 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -392,6 +392,45 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(diagnostics).toEqual([]); }); + test("resolves a direct object-ID xcconfig reference", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + configurations: ["Debug"], + files: [{ id: "TARGET-CONFIG", path: "Config/Target.xcconfig" }], + "build-settings": { SDKROOT: "iphoneos" }, + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: "id:TARGET-CONFIG", + }, + ], + "build-settings": { + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }, + async (root) => { + await mkdir(join(root, "Config"), { recursive: true }); + await Bun.write( + join(root, "Config", "Target.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Actual\nDEVELOPMENT_TEAM = DIRECTID12", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Actual", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "DIRECTID12", + }); + expect(diagnostics).toEqual([]); + }); + test("fails synchronized-folder anchors closed when the name is ambiguous or the ID is missing", async () => { for (const { files, anchor } of [ { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts index c621bcc5a..45b0de3f4 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -298,6 +298,7 @@ function configurationFilePath( ): string | undefined { if (!file) return undefined; if (typeof file === "string") { + if (file.startsWith("id:")) return anchoredReferencePath(file, index); const matches = index.files.get(normalizeConfigurationReferenceToken(file)) ?? []; return matches.length === 1 ? matches[0] : undefined; } diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 0074e4ba6..9a72ff63e 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -513,7 +513,10 @@ async function sourceFilesForTarget(options: { const path = typeof reference.path === "string" ? reference.path : ""; if (kind === "group") { const groupDirectory = path ? sourceReferencePath(projectDirectory, parent, path) : parent; - if (!groupDirectory) return; + if (!groupDirectory) { + state.complete = false; + return; + } let children: unknown[]; try { children = reference.children === undefined ? [] : xcprojArray(reference.children); @@ -530,7 +533,10 @@ async function sourceFilesForTarget(options: { return; } const directory = sourceReferencePath(projectDirectory, parent, path); - if (!directory) return; + if (!directory) { + state.complete = false; + return; + } const membership = folderMembership(reference, target, resolveBuildPhase, platform, state); if (membership.member) { await collectSwiftFiles(root, directory, directory, membership.included, files, state); From 4e501ecd9c72099ee10a2338ceefc9cbe47d5c5b Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 22 Sep 2026 09:07:06 -0400 Subject: [PATCH 06/10] fix: align Xcode JSON reference semantics --- .../commands/init/ios/direct-config.test.ts | 60 ++++++++++++++ .../src/commands/init/ios/inspect.test.ts | 71 +++++++++++++++++ .../init/ios/xcproj-build-settings.test.ts | 79 +++++++++++++++++++ .../init/ios/xcproj-build-settings.ts | 49 +++--------- .../src/commands/init/ios/xcproj-inspect.ts | 12 +++ .../src/commands/init/ios/xcproj.test.ts | 26 ++++++ .../cli-core/src/commands/init/ios/xcproj.ts | 11 ++- 7 files changed, 271 insertions(+), 37 deletions(-) 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 a524f3223..a0a449846 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 @@ -793,6 +793,66 @@ struct MyApp: App { expect(await readFile(absoluteSharedSourcePath)).toEqual(before); }); + test("ignores an @main Swift template below an opaque JSON folder", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-opaque-folder-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const templateDirectory = join(root, "MyApp", "Templates"); + await mkdir(templateDirectory, { recursive: true }); + await writeFile( + join(templateDirectory, "TemplateApp.swift"), + `import SwiftUI + +@main +struct TemplateApp: App { + var body: some Scene { WindowGroup { Text("Template") } } +} +`, + ); + await writeFile( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "opaque-folders"], + ["Templates"], + ), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("ready"); + expect(plan.sourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test("refuses mutation when JSON opaque-folder metadata is malformed", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-malformed-opaque-folder-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await writeFile( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "opaque-folders"], + "Templates", + ), + ); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test.each([ ["named", "SharedTarget/compile-sources/Shared Sources"], ["ID-based", "id:SHARED-SOURCES-PHASE"], 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 af83a6731..84eb768f4 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -983,6 +983,77 @@ describe("inspectIOSProject", () => { expect(inspection.diagnostics).toEqual([]); }); + test("does not inspect Swift sources below an opaque JSON folder", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-opaque-folder-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const templateDirectory = join(root, "MyApp", "Templates"); + await mkdir(templateDirectory, { recursive: true }); + await Bun.write( + join(templateDirectory, "TemplateApp.swift"), + `import SwiftUI + +@main +struct TemplateApp: App { + var body: some Scene { WindowGroup { Text("Template") } } +} +`, + ); + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "opaque-folders"], + ["Templates"], + ), + ); + + const inspection = await inspectIOSProject(root); + const membership = (await inspectIOSSourceMembership(root)).find( + (candidate) => candidate.targetId === "C1E000000000000000000001", + ); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 2, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + }); + expect(membership).toMatchObject({ complete: true }); + expect(membership?.files.map((file) => file.relativePath)).not.toContain( + "MyApp/Templates/TemplateApp.swift", + ); + expect(inspection.diagnostics).toEqual([]); + }); + + test("marks malformed JSON opaque-folder metadata incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-malformed-opaque-folder-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "opaque-folders"], + "Templates", + ), + ); + + const inspection = await inspectIOSProject(root); + const membership = (await inspectIOSSourceMembership(root)).find( + (candidate) => candidate.targetId === "C1E000000000000000000001", + ); + + expect(inspection.appTargets[0]?.swift.evidenceComplete).toBe(false); + expect(membership?.complete).toBe(false); + expect( + inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.incomplete-source-membership", + ), + ).toBe(true); + }); + test("extracts the App ID Prefix when Bundle ID casing differs", async () => { const root = await fixture({ complete: true }); const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts index d36bca9ec..d21663e17 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.test.ts @@ -641,6 +641,85 @@ describe("inspectXCProjTargetBuildConfigurations", () => { expect(diagnostics).toEqual([]); }); + test("resolves a root xcconfig by logical path when a nested file has the same name", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + configurations: [{ name: "Debug", file: "Base.xcconfig" }], + "build-settings": { SDKROOT: "iphoneos" }, + files: [ + { path: "Base.xcconfig" }, + { + kind: "group", + path: "Other", + children: [{ path: "Base.xcconfig" }], + }, + ], + }, + {}, + async (root) => { + await mkdir(join(root, "Other"), { recursive: true }); + await Bun.write( + join(root, "Base.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Actual\nDEVELOPMENT_TEAM = ACTUAL1234", + ); + await Bun.write( + join(root, "Other", "Base.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.Wrong\nDEVELOPMENT_TEAM = WRONG12345", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.Actual.Example", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "ABCDE12345", + }); + expect(diagnostics).toEqual([]); + }); + + test("resolves component-array xcconfig references through the logical tree", async () => { + const { configurations, diagnostics } = await inspectFixture( + { + files: [ + { + kind: "group", + name: "Build/Settings", + path: "PhysicalSettings", + children: [{ path: "Base.xcconfig" }], + }, + ], + }, + { + "specialized-configurations": [ + { + name: "Debug", + file: [{ name: "Build/Settings" }, "Base.xcconfig"], + }, + ], + }, + async (root) => { + await mkdir(join(root, "PhysicalSettings"), { recursive: true }); + await Bun.write( + join(root, "PhysicalSettings", "Base.xcconfig"), + "PRODUCT_BUNDLE_IDENTIFIER = com.example.ComponentArray\nDEVELOPMENT_TEAM = ARRAY12345", + ); + }, + ); + + expect(configurations[0]?.model.bundleIdentifier).toMatchObject({ + state: "resolved", + value: "com.example.ComponentArray.Example", + }); + expect(configurations[0]?.model.developmentTeam).toMatchObject({ + state: "resolved", + value: "ABCDE12345", + }); + expect(diagnostics).toEqual([]); + }); + test("fails string-form xcconfig resolution closed when a filename is ambiguous", async () => { const { configurations, diagnostics } = await inspectFixture( { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts index 45b0de3f4..7d4a99926 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-build-settings.ts @@ -1,4 +1,4 @@ -import { basename, dirname, relative, resolve } from "node:path"; +import { basename, dirname, resolve } from "node:path"; import { inspectTargetBuildConfigurations, type InspectedTargetConfiguration, @@ -14,9 +14,11 @@ import { type XCProjTarget, } from "./xcproj.ts"; +type XCProjConfigurationFile = string | unknown[] | XCProjRecord; + interface XCProjConfiguration { name: string; - file?: string | XCProjRecord; + file?: XCProjConfigurationFile; } export interface InspectXCProjTargetBuildConfigurationsOptions { @@ -78,14 +80,16 @@ function configuration(value: unknown): XCProjConfiguration { const name = xcprojString(record.name); if (name === "") invalidSchema(); if (record.file === "") invalidSchema(); + const file = record.file; + if (Array.isArray(file) && !namePathComponents(file)) invalidSchema(); return { name, file: - record.file === undefined + file === undefined ? undefined - : typeof record.file === "string" - ? record.file - : xcprojRecord(record.file), + : typeof file === "string" || Array.isArray(file) + ? file + : xcprojRecord(file), }; } @@ -107,7 +111,6 @@ interface IndexedProjectReference { } interface ConfigurationReferenceIndex { - files: ReadonlyMap; referencesById: ReadonlyMap; referencesByNamePath: ReadonlyMap; } @@ -164,28 +167,13 @@ function projectReferencePath( return resolve(parent, path); } -function normalizeConfigurationReferenceToken(token: string): string { - return token.replaceAll("\\", "/").replace(/^\.\//, ""); -} - function configurationReferenceIndex( projectPath: string, project: XCProjRecord, ): ConfigurationReferenceIndex { const projectDirectory = dirname(projectPath); - const files = new Map(); const referencesById = new Map(); const referencesByNamePath = new Map(); - const addFile = (tokens: readonly string[], path: string): void => { - const normalizedTokens = new Set( - tokens.map(normalizeConfigurationReferenceToken).filter(Boolean), - ); - for (const normalizedToken of normalizedTokens) { - const matches = files.get(normalizedToken) ?? []; - matches.push(path); - files.set(normalizedToken, matches); - } - }; const addReference = ( logicalPath: readonly string[], path: string, @@ -244,18 +232,11 @@ function configurationReferenceIndex( const absolutePath = projectReferencePath(projectDirectory, parent, path); if (!absolutePath) return; addReference(logicalPath, absolutePath, reference.id, Boolean(logicalName)); - const logicalToken = logicalPath.join("/"); - addFile( - [logicalName, path, logicalToken, relative(projectDirectory, absolutePath)], - absolutePath, - ); }; for (const reference of xcprojArray(project.files ?? [])) { visit(reference, projectDirectory, []); } - const sorted = (matches: string[]): string[] => [...matches].sort(); return { - files: new Map([...files].map(([token, matches]) => [token, sorted(matches)] as const)), referencesById, referencesByNamePath, }; @@ -293,15 +274,11 @@ function anchoredReferencePath( } function configurationFilePath( - file: string | XCProjRecord | undefined, + file: XCProjConfigurationFile | undefined, index: ConfigurationReferenceIndex, ): string | undefined { if (!file) return undefined; - if (typeof file === "string") { - if (file.startsWith("id:")) return anchoredReferencePath(file, index); - const matches = index.files.get(normalizeConfigurationReferenceToken(file)) ?? []; - return matches.length === 1 ? matches[0] : undefined; - } + if (typeof file === "string" || Array.isArray(file)) return anchoredReferencePath(file, index); const anchorPath = anchoredReferencePath(file.anchor, index); const relativePath = fileSystemNamePath(file["relative-path"]); if (!anchorPath || relativePath === undefined) return undefined; @@ -311,7 +288,7 @@ function configurationFilePath( function attachBaseConfiguration( objects: PbxObjects, configurationObject: PbxObject, - file: string | XCProjRecord | undefined, + file: XCProjConfigurationFile | undefined, index: ConfigurationReferenceIndex, referenceId: string, ): void { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 9a72ff63e..4c7a0c727 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -351,6 +351,17 @@ function folderMembership( platform: IOSNativePlatform | undefined, state: { complete: boolean }, ): { member: boolean; included: (path: string) => boolean } { + let opaqueFolders: string[]; + try { + opaqueFolders = + reference["opaque-folders"] === undefined + ? [] + : xcprojStringArray(reference["opaque-folders"]); + } catch { + state.complete = false; + opaqueFolders = []; + } + const opaque = new Set(opaqueFolders.map(normalizedPath)); let members: string[]; try { members = @@ -428,6 +439,7 @@ function folderMembership( return { member: defaultMember || inclusions.size > 0, included(path) { + if (matchesPath(opaque, path)) return false; const base = defaultMember ? !matchesPath(exclusions, path) : matchesPathOrIncludedDescendant(inclusions, path); diff --git a/packages/cli-core/src/commands/init/ios/xcproj.test.ts b/packages/cli-core/src/commands/init/ios/xcproj.test.ts index eb736bcfc..2e71885d7 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.test.ts @@ -106,6 +106,32 @@ describe("parseXCProjSource", () => { expect(parseXCProjSource(bytes).root["default-configuration"]).toBe("Release"); }); + test("accepts component-array configuration file references", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '{ "anchor": "App", "relative-path": "Config.xcconfig" }', + '[ { "name": "Build/Settings" }, "Base.xcconfig" ]', + ); + + expect(parseXCProjSource(source).root.configurations).toEqual([ + "Debug", + { + name: "Release", + file: [{ name: "Build/Settings" }, "Base.xcconfig"], + }, + ]); + }); + + test("rejects malformed component-array configuration file references", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '{ "anchor": "App", "relative-path": "Config.xcconfig" }', + '[ { "wrong": "Build/Settings" }, "Base.xcconfig" ]', + ); + + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "invalid-schema" }), + ); + }); + test("rejects input before decoding when it exceeds the byte bound", () => { expect(() => parseXCProjSource(XCODE_GENERATED_PROJECT, { maxBytes: 16 })).toThrow( expect.objectContaining({ code: "too-large" }), diff --git a/packages/cli-core/src/commands/init/ios/xcproj.ts b/packages/cli-core/src/commands/init/ios/xcproj.ts index 04a717444..2cc3899e1 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.ts @@ -179,7 +179,16 @@ function validateConfiguration(value: unknown): void { const record = xcprojRecord(value); xcprojString(record.name); optionalString(record, "id"); - if (record.file !== undefined && typeof record.file !== "string") xcprojRecord(record.file); + if (record.file !== undefined && typeof record.file !== "string") { + if (Array.isArray(record.file)) { + for (const component of xcprojArray(record.file)) { + if (typeof component === "string") continue; + xcprojString(xcprojRecord(component).name); + } + } else { + xcprojRecord(record.file); + } + } } function normalizeBuildPhase(value: unknown): XCProjBuildPhase { From 4594e1e474688ad1006bec21a8713520516f6076 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 22 Sep 2026 09:48:38 -0400 Subject: [PATCH 07/10] fix: harden Xcode JSON phase references --- .../src/commands/init/ios/inspect.test.ts | 90 +++++++++++ .../src/commands/init/ios/install-sdk.test.ts | 152 ++++++++++++++++++ .../src/commands/init/ios/xcproj-inspect.ts | 49 ++---- .../commands/init/ios/xcproj-install-sdk.ts | 69 +++++--- .../src/commands/init/ios/xcproj.test.ts | 13 ++ .../cli-core/src/commands/init/ios/xcproj.ts | 63 +++++++- 6 files changed, 384 insertions(+), 52 deletions(-) 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 84eb768f4..d765b2ae7 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -884,6 +884,96 @@ describe("inspectIOSProject", () => { expect(inspection.diagnostics).toEqual([]); }); + test("accepts explicit file references and localized variant groups during source inspection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-reference-kinds-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files"], + [ + { + kind: "group", + path: "MyApp", + children: [ + { + kind: "file-reference", + path: "MyAppApp.swift", + "target-membership": ["MyApp/compile-sources"], + }, + { + kind: "file-reference", + path: "ContentView.swift", + "target-membership": ["MyApp/compile-sources"], + }, + { + kind: "variant-group", + name: "Localizable.strings", + children: [{ kind: "file-reference", path: "en.lproj/Localizable.strings" }], + }, + ], + }, + ], + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 2, + entryPoints: [{ path: "MyApp/MyAppApp.swift" }], + }); + expect(inspection.diagnostics).toEqual([]); + }); + + test("recognizes an Xcode JSON Clerk product linked through a build-phase ID", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-package-phase-id-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await readFile(projectPath, "utf8"); + source = applyXCProjValue(source, ["targets", 0, "build-phases", 1], { + kind: "frameworks", + id: "FRAMEWORKS-PHASE-ID", + }); + source = applyXCProjValue( + source, + ["packages"], + [ + { + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + version: { "up-to-next-major-version": "1.0.0" }, + }, + ], + ); + source = applyXCProjValue( + source, + ["targets", 0, "package-product-members"], + [ + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "id:FRAMEWORKS-PHASE-ID" }, + }, + ], + ); + await Bun.write(projectPath, source); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(inspection.diagnostics).toEqual([]); + }); + test("resolves project-anchored JSON source references from nested groups", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-project-anchor-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index af7da987c..b6d1b178b 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -338,6 +338,158 @@ describe("iOS Clerk SDK installer", () => { expect(await readFile(path)).toEqual(before); }); + test("resolves an existing Xcode JSON package link through its Frameworks phase ID", async () => { + const root = await temporaryRoot("clerk-xcproj-phase-id-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await Bun.file(path).text(); + source = applyXCProjValue(source, ["targets", 0, "build-phases", 1], { + kind: "frameworks", + id: "FRAMEWORKS-PHASE-ID", + }); + source = applyXCProjValue( + source, + ["packages"], + [ + { + kind: "remote", + repository: "https://github.com/clerk/clerk-ios.git", + version: { "up-to-next-major-version": DEFAULT_CLERK_IOS_MINIMUM_VERSION }, + }, + ], + ); + source = applyXCProjValue( + source, + ["targets", 0, "package-product-members"], + [ + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": "id:FRAMEWORKS-PHASE-ID" }, + }, + ], + ); + await Bun.write(path, source); + const before = await readFile(path); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("satisfied"); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "satisfied" }); + expect(await readFile(path)).toEqual(before); + }); + + test("blocks unresolved or non-Frameworks Xcode JSON package phase IDs", async () => { + for (const item of [ + { name: "missing", reference: "id:MISSING-PHASE-ID" }, + { name: "sources", reference: "id:SOURCES-PHASE-ID" }, + ]) { + const root = await temporaryRoot(`clerk-xcproj-${item.name}-phase-id-`); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await Bun.file(path).text(); + source = applyXCProjValue(source, ["targets", 0, "build-phases", 0], { + kind: "compile-sources", + id: "SOURCES-PHASE-ID", + }); + source = applyXCProjValue( + source, + ["packages"], + [ + { + kind: "remote", + repository: "https://github.com/clerk/clerk-ios.git", + version: { "up-to-next-major-version": DEFAULT_CLERK_IOS_MINIMUM_VERSION }, + }, + ], + ); + source = applyXCProjValue( + source, + ["targets", 0, "package-product-members"], + [ + { + package: "clerk-ios", + "product-name": "ClerkKit", + "build-phase": { "build-phase": item.reference }, + }, + ], + ); + await Bun.write(path, source); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "ambiguous-frameworks-phase" }], + }); + } + }); + + test("writes an unambiguous package link for one explicit Xcode JSON Frameworks phase", async () => { + const root = await temporaryRoot("clerk-xcproj-explicit-frameworks-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + const source = applyXCProjValue( + await Bun.file(path).text(), + ["targets", 0, "build-phases", 1], + { kind: "frameworks", name: "Dependencies" }, + ); + await Bun.write(path, source); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("ready"); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "applied" }); + const target = xcprojTargets(parseXCProjSource(await readFile(path)).root)[0]!; + expect(target.packageProductMembers[0]?.["build-phase"]).toEqual({ + "build-phase": "frameworks", + }); + }); + + test("blocks Xcode JSON package writes when Frameworks phase selection is ambiguous", async () => { + const root = await temporaryRoot("clerk-xcproj-ambiguous-frameworks-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + path, + applyXCProjValue( + await Bun.file(path).text(), + ["targets", 0, "build-phases"], + [ + "compile-sources", + { kind: "frameworks", name: "App Frameworks", id: "FRAMEWORKS-PHASE-1" }, + { kind: "frameworks", name: "Generated Frameworks", id: "FRAMEWORKS-PHASE-2" }, + "resources", + ], + ), + ); + const before = await readFile(path); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "ambiguous-frameworks-phase" }], + }); + expect(await readFile(path)).toEqual(before); + }); + test("rejects duplicate unrestricted Xcode JSON product links", async () => { const root = await temporaryRoot("clerk-xcproj-duplicate-platforms-"); await createIOSJSONFixture(root); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 4c7a0c727..2ab55aaf5 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -23,8 +23,10 @@ import { type XCProjRecord, type XCProjTarget, xcprojArray, + xcprojNamePathChildNames, xcprojPackages, xcprojRecord, + resolveXCProjTargetBuildPhaseReference, xcprojString, xcprojStringArray, xcprojTargets, @@ -161,17 +163,25 @@ function inspectTargetPackages( if (matching.length === 0) return { state: "absent", packageNames: [] }; let linked = false; let evidenceComplete = true; + const incompleteReasons = new Set(); for (const member of matching) { const phase = xcprojRecord(member["build-phase"]); + const resolvedPhase = resolveXCProjTargetBuildPhaseReference(target, phase["build-phase"]); + if (!resolvedPhase) { + evidenceComplete = false; + incompleteReasons.add("an unresolved build-phase reference"); + continue; + } const applicability = platformFiltersApply(phase.platforms, platform); evidenceComplete &&= applicability.complete; - linked ||= phase["build-phase"] === "frameworks" && applicability.applies; + if (!applicability.complete) incompleteReasons.add("an unrecognized platform filter"); + linked ||= resolvedPhase.kind === "frameworks" && applicability.applies; } if (!evidenceComplete) { diagnostics.push({ code: "clerk.package-unattributed", severity: "warning", - message: `${target.name} contains an unrecognized platform filter on ${productName}.`, + message: `${target.name} contains ${[...incompleteReasons].join(" and ")} on ${productName}.`, evidence: [{ path: relativeIOSPath(root, resolve(projectPath, "project.xcproj")) }], }); } @@ -221,31 +231,6 @@ interface ResolvedProjectBuildPhase { type ProjectBuildPhaseResolver = (value: unknown) => ResolvedProjectBuildPhase | undefined; -function buildPhaseNamePath(value: unknown): string[] | undefined { - if (typeof value === "string") return value.split("/"); - if (!Array.isArray(value)) return undefined; - const components: string[] = []; - for (const raw of value) { - if (typeof raw === "string") { - // A literal child named "." or ".." is encoded as { name }, while a - // bare string has relative-path semantics and is invalid in this reference. - if (raw === "." || raw === "..") return undefined; - components.push(raw); - continue; - } - if ( - typeof raw !== "object" || - raw === null || - Array.isArray(raw) || - typeof (raw as XCProjRecord).name !== "string" - ) { - return undefined; - } - components.push((raw as XCProjRecord).name as string); - } - return components; -} - function projectBuildPhaseResolver(document: XCProjRecord): ProjectBuildPhaseResolver { const targets = xcprojTargets(document); return (value) => { @@ -264,7 +249,7 @@ function projectBuildPhaseResolver(document: XCProjRecord): ProjectBuildPhaseRes return matches.length === 1 ? matches[0] : undefined; } - const components = buildPhaseNamePath(value); + const components = xcprojNamePathChildNames(value); if (!components || (components.length !== 2 && components.length !== 3)) return undefined; const [targetName, kind, phaseName] = components; const matches = targets.flatMap((target) => @@ -272,8 +257,7 @@ function projectBuildPhaseResolver(document: XCProjRecord): ProjectBuildPhaseRes ? target.buildPhases .filter( (phase) => - phase.kind === kind && - (phaseName === undefined ? phase.name === undefined : phase.name === phaseName), + phase.kind === kind && (phaseName === undefined || phase.name === phaseName), ) .map((phase) => ({ targetId: target.id, @@ -521,7 +505,7 @@ async function sourceFilesForTarget(options: { state.complete = false; return; } - const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const kind = typeof reference.kind === "string" ? reference.kind : "file-reference"; const path = typeof reference.path === "string" ? reference.path : ""; if (kind === "group") { const groupDirectory = path ? sourceReferencePath(projectDirectory, parent, path) : parent; @@ -555,7 +539,8 @@ async function sourceFilesForTarget(options: { } return; } - if (kind !== "file") { + if (kind === "variant-group" || kind === "version-group") return; + if (kind !== "file-reference" && kind !== "file") { state.complete = false; return; } diff --git a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts index 119759101..247dd0761 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts @@ -7,10 +7,13 @@ import type { IOSNativePlatform } from "./types.ts"; import { applyXCProjValue, parseXCProjSource, + type XCProjBuildPhase, type XCProjRecord, type XCProjSwiftPackage, + type XCProjTarget, xcprojPackages, xcprojRecord, + resolveXCProjTargetBuildPhaseReference, xcprojString, xcprojStringArray, xcprojTargets, @@ -262,8 +265,8 @@ function compatibilityBlocker( ); } -function productMembers(target: XCProjRecord): ProductMember[] | XCProjSDKInstallPreparation { - const rawMembers = target["package-product-members"]; +function productMembers(target: XCProjTarget): ProductMember[] | XCProjSDKInstallPreparation { + const rawMembers = target.raw["package-product-members"]; if (rawMembers === undefined) return []; if (!Array.isArray(rawMembers)) { return blocked("unsupported-project", "The selected target has malformed package products."); @@ -275,11 +278,11 @@ function productMembers(target: XCProjRecord): ProductMember[] | XCProjSDKInstal if (!PRODUCT_NAMES.has(productName)) continue; const packageValue = member.package; const buildPhase = xcprojRecord(member["build-phase"]); - const phase = xcprojString(buildPhase["build-phase"]); - if (phase !== "frameworks") { + const phase = resolveXCProjTargetBuildPhaseReference(target, buildPhase["build-phase"]); + if (!phase || phase.kind !== "frameworks") { return blocked( "ambiguous-frameworks-phase", - `${productName} is attached to a non-Frameworks build phase.`, + `${productName} is attached to an unresolved or non-Frameworks build phase.`, ); } const platforms = @@ -341,17 +344,14 @@ function validateProductMembers( return undefined; } -function hasFrameworksPhase(target: ReturnType[number]): boolean { - return target.buildPhases.some((phase) => phase.kind === "frameworks"); -} - function memberValue( packageIdentity: string, product: XCProjSDKProduct, missingPlatforms: IOSNativePlatform[], allPlatforms: IOSNativePlatform[], + buildPhaseReference: string, ): XCProjRecord { - const buildPhase: XCProjRecord = { "build-phase": "frameworks" }; + const buildPhase: XCProjRecord = { "build-phase": buildPhaseReference }; if (missingPlatforms.length !== allPlatforms.length) buildPhase.platforms = missingPlatforms; return { package: packageIdentity, @@ -360,6 +360,12 @@ function memberValue( }; } +function targetBuildPhaseReferenceValue(phase: XCProjBuildPhase): string { + // Prefer object identity. When no ID exists, Xcode resolves the bare kind + // only when that kind is unique; the caller proves that before writing. + return phase.id ? `id:${phase.id}` : phase.kind; +} + /** * Plans the format-specific part of the Clerk SDK mutation for project.xcproj. * The caller owns target/platform inspection plus transactional installation. @@ -429,7 +435,7 @@ export async function prepareXCProjSDKInstall( if (compatibility) return compatibility; } - const membersResult = productMembers(selected.target.raw); + const membersResult = productMembers(selected.target); if (!Array.isArray(membersResult)) return membersResult; const memberBlocker = validateProductMembers( membersResult, @@ -438,7 +444,21 @@ export async function prepareXCProjSDKInstall( ); if (memberBlocker) return memberBlocker; - if (!hasFrameworksPhase(selected.target)) { + const missingProducts = options.products.map((product) => { + const existing = membersResult.filter((member) => member.product === product); + return { + product, + missingPlatforms: options.supportedPlatforms.filter( + (platform) => !existing.some((member) => appliesToPlatform(member, platform)), + ), + }; + }); + const needsProductLink = missingProducts.some((item) => item.missingPlatforms.length > 0); + let buildPhaseReference: string | undefined; + const frameworksPhases = selected.target.buildPhases.filter( + (phase) => phase.kind === "frameworks", + ); + if (needsProductLink && frameworksPhases.length === 0) { const rawPhases = selected.target.raw["build-phases"]; if (!Array.isArray(rawPhases)) { return blocked( @@ -452,22 +472,33 @@ export async function prepareXCProjSDKInstall( "frameworks", ); actions.push("Create a Frameworks build phase for the selected target."); + buildPhaseReference = "frameworks"; + } else if (needsProductLink && frameworksPhases.length === 1) { + buildPhaseReference = targetBuildPhaseReferenceValue(frameworksPhases[0]!); + } else if (needsProductLink) { + return blocked( + "ambiguous-frameworks-phase", + "The selected target has more than one Frameworks build phase, so Clerk cannot choose where to link the SDK safely.", + ); } let memberCount = Array.isArray(selected.target.raw["package-product-members"]) ? selected.target.raw["package-product-members"].length : 0; - for (const product of options.products) { - const productMembers = membersResult.filter((member) => member.product === product); - const missingPlatforms = options.supportedPlatforms.filter( - (platform) => !productMembers.some((member) => appliesToPlatform(member, platform)), - ); + for (const { product, missingPlatforms } of missingProducts) { if (missingPlatforms.length === 0) continue; + if (!buildPhaseReference) { + return blocked( + "ambiguous-frameworks-phase", + "The selected target's Frameworks build phase could not be referenced safely.", + ); + } const value = memberValue( selectedPackage.identity, product, missingPlatforms, options.supportedPlatforms, + buildPhaseReference, ); if (memberCount === 0 && selected.target.raw["package-product-members"] === undefined) { candidate = applyXCProjValue( @@ -523,7 +554,7 @@ export function validateXCProjSDKInstallPostcondition( ) { return false; } - const membersResult = productMembers(targets[0].raw); + const membersResult = productMembers(targets[0]); if (!Array.isArray(membersResult)) return false; const packages = xcprojPackages(parsed.root); const referencedIdentities = new Set( @@ -549,7 +580,7 @@ export function validateXCProjSDKInstallPostcondition( if (validateProductMembers(membersResult, verifiedPackage, options.supportedPlatforms)) { return false; } - if (!hasFrameworksPhase(targets[0])) return false; + if (!targets[0].buildPhases.some((phase) => phase.kind === "frameworks")) return false; return options.products.every((product) => options.supportedPlatforms.every((platform) => membersResult.some( diff --git a/packages/cli-core/src/commands/init/ios/xcproj.test.ts b/packages/cli-core/src/commands/init/ios/xcproj.test.ts index 2e71885d7..ffcc27fd6 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.test.ts @@ -121,6 +121,19 @@ describe("parseXCProjSource", () => { ]); }); + test("accepts component-array target build-phase references", () => { + const source = XCODE_GENERATED_PROJECT.replace( + '"build-phase": { "build-phase": "frameworks" }', + '"build-phase": { "build-phase": [ "frameworks", { "name": "App/Dependencies" } ] }', + ); + + expect(xcprojTargets(parseXCProjSource(source).root)[0]?.packageProductMembers[0]).toEqual( + expect.objectContaining({ + "build-phase": { "build-phase": ["frameworks", { name: "App/Dependencies" }] }, + }), + ); + }); + test("rejects malformed component-array configuration file references", () => { const source = XCODE_GENERATED_PROJECT.replace( '{ "anchor": "App", "relative-path": "Config.xcconfig" }', diff --git a/packages/cli-core/src/commands/init/ios/xcproj.ts b/packages/cli-core/src/commands/init/ios/xcproj.ts index 2cc3899e1..3b0742d98 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.ts @@ -213,6 +213,67 @@ export function xcprojBuildPhases(target: XCProjRecord): XCProjBuildPhase[] { return value === undefined ? [] : xcprojArray(value).map(normalizeBuildPhase); } +type ParsedTargetBuildPhaseReference = + | { kind: "id"; id: string } + | { kind: "named"; phaseKind: XCProjBuildPhaseKind; name?: string }; + +export function xcprojNamePathChildNames(value: unknown): string[] | undefined { + const rawComponents = typeof value === "string" ? value.split("/") : value; + if (!Array.isArray(rawComponents)) return undefined; + const components: string[] = []; + for (const raw of rawComponents) { + if (typeof raw === "string") { + if (raw === "." || raw === ".." || (Array.isArray(value) && raw.includes("/"))) { + return undefined; + } + components.push(raw); + continue; + } + if ( + typeof raw !== "object" || + raw === null || + Array.isArray(raw) || + typeof (raw as XCProjRecord).name !== "string" + ) { + return undefined; + } + components.push((raw as XCProjRecord).name as string); + } + return components; +} + +function parseTargetBuildPhaseReference( + value: unknown, +): ParsedTargetBuildPhaseReference | undefined { + if (typeof value === "string" && value.startsWith("id:")) { + const id = value.slice("id:".length); + return id ? { kind: "id", id } : undefined; + } + const components = xcprojNamePathChildNames(value); + if (!components || components.length < 1 || components.length > 2) return undefined; + const [kind, name] = components; + if (!BUILD_PHASE_KINDS.has(kind as XCProjBuildPhaseKind)) return undefined; + return { kind: "named", phaseKind: kind as XCProjBuildPhaseKind, name }; +} + +/** Resolves Xcode's target-relative build-phase reference against one target. */ +export function resolveXCProjTargetBuildPhaseReference( + target: XCProjTarget, + value: unknown, +): XCProjBuildPhase | undefined { + const reference = parseTargetBuildPhaseReference(value); + if (!reference) return undefined; + const matches = target.buildPhases.filter((phase) => + reference.kind === "id" + ? phase.id === reference.id + : phase.kind === reference.phaseKind && + // Xcode 27.2 resolves a kind-only reference to the sole phase of that + // kind even when the phase carries a display name. + (reference.name === undefined || phase.name === reference.name), + ); + return matches.length === 1 ? matches[0] : undefined; +} + function validatePackageVersion(value: unknown): XCProjRecord { const version = xcprojRecord(value); const presentKeys = PACKAGE_VERSION_KEYS.filter((key) => version[key] !== undefined); @@ -264,7 +325,7 @@ function validatePackageProductMember(value: unknown): XCProjRecord { optionalString(member, "product-type"); const buildPhase = xcprojRecord(member["build-phase"]); optionalString(buildPhase, "id"); - xcprojString(buildPhase["build-phase"]); + if (!parseTargetBuildPhaseReference(buildPhase["build-phase"])) schemaError(); optionalStringArray(buildPhase, "platforms"); return member; } From 0e9df28eb85d595ad9e8c123ec0ea420b01c4b1e Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 22 Sep 2026 11:03:30 -0400 Subject: [PATCH 08/10] fix: align Xcode JSON membership semantics --- .../commands/init/ios/direct-config.test.ts | 263 ++++++++++++++++++ .../init/ios/entitlements-settings.test.ts | 36 +++ .../init/ios/entitlements-settings.ts | 5 +- .../src/commands/init/ios/install-sdk.test.ts | 32 +++ .../src/commands/init/ios/xcproj-inspect.ts | 105 +++++-- .../commands/init/ios/xcproj-install-sdk.ts | 10 +- .../src/commands/init/ios/xcproj.test.ts | 14 + .../cli-core/src/commands/init/ios/xcproj.ts | 1 + 8 files changed, 443 insertions(+), 23 deletions(-) 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 a0a449846..73b8dda0b 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 @@ -828,6 +828,222 @@ struct TemplateApp: App { expect(plan.sourcePath).toBe("MyApp/MyAppApp.swift"); }); + test.each([ + "Templates.bundle", + "Docs.docc", + "en.lproj", + "Demo.playground", + "Assets.xcassets", + "Model.xcdatamodeld", + "Page.xcplaygroundpage", + ])("ignores an @main Swift template inside the JSON resource package %s", async (packageName) => { + const root = await temporaryRoot("clerk-xcproj-direct-config-resource-bundle-"); + await createIOSJSONFixture(root); + const templateDirectory = join(root, "MyApp", packageName); + await mkdir(templateDirectory, { recursive: true }); + await writeFile( + join(templateDirectory, "TemplateApp.swift"), + `import SwiftUI + +@main +struct TemplateApp: App { + var body: some Scene { WindowGroup { Text("Template") } } +} +`, + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("ready"); + expect(plan.sourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test.each(["Pods", "build", ".generated"])( + "keeps JSON synchronized Swift sources below the compile-eligible directory %s", + async (directoryName) => { + const root = await temporaryRoot("clerk-xcproj-direct-config-compile-directory-"); + await createIOSJSONFixture(root); + const generatedDirectory = join(root, "MyApp", directoryName); + await mkdir(generatedDirectory, { recursive: true }); + await writeFile( + join(generatedDirectory, "GeneratedApp.swift"), + `import SwiftUI + +@main +struct GeneratedApp: App { + var body: some Scene { WindowGroup { Text("Generated") } } +} +`, + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("ambiguous-entry-point"); + }, + ); + + test("recognizes mixed-case Swift extensions in a JSON synchronized folder", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-swift-case-"); + await createIOSJSONFixture(root); + await writeFile( + join(root, "MyApp", "GeneratedApp.SwIfT"), + `import SwiftUI + +@main +struct GeneratedApp: App { + var body: some Scene { WindowGroup { Text("Generated") } } +} +`, + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("ambiguous-entry-point"); + }); + + test("honors JSON file-type overrides that exclude a .swift file from compilation", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-nonswift-override-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await writeFile( + join(root, "MyApp", "TemplateApp.swift"), + `import SwiftUI + +@main +struct TemplateApp: App { + var body: some Scene { WindowGroup { Text("Template") } } +} +`, + ); + await writeFile( + projectPath, + applyXCProjValue(await readFile(projectPath, "utf8"), ["files", 0, "file-types"], { + "TemplateApp.swift": "text", + }), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("ready"); + expect(plan.sourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test("honors JSON file-type overrides that compile a non-.swift file", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-swift-override-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await writeFile( + join(root, "MyApp", "GeneratedApp.txt"), + `import SwiftUI + +@main +struct GeneratedApp: App { + var body: some Scene { WindowGroup { Text("Generated") } } +} +`, + ); + await writeFile( + projectPath, + applyXCProjValue(await readFile(projectPath, "utf8"), ["files", 0, "file-types"], { + "GeneratedApp.txt": "sourcecode.swift", + }), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("ambiguous-entry-point"); + }); + + test("refuses mutation when JSON file-type metadata is malformed", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-malformed-file-types-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await writeFile( + projectPath, + applyXCProjValue(await readFile(projectPath, "utf8"), ["files", 0, "file-types"], { + "MyAppApp.swift": 42, + }), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("incomplete-source-membership"); + }); + + test.each([ + ["TemplateApp.swift", "text", "ready"], + ["GeneratedApp.txt", "sourcecode.swift", "blocked"], + ] as const)( + "honors the %s explicit JSON file-reference type %s", + async (fileName, fileType, expectedStatus) => { + const root = await temporaryRoot("clerk-xcproj-direct-config-reference-type-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const generatedDirectory = join(root, "Generated"); + await mkdir(generatedDirectory, { recursive: true }); + await writeFile( + join(generatedDirectory, fileName), + `import SwiftUI + +@main +struct GeneratedApp: App { + var body: some Scene { WindowGroup { Text("Generated") } } +} +`, + ); + await writeFile( + projectPath, + applyXCProjValue(await readFile(projectPath, "utf8"), ["files", 2], { + kind: "file-reference", + path: `/Generated/${fileName}`, + type: fileType, + "target-membership": ["MyApp/compile-sources"], + }), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe(expectedStatus); + if (expectedStatus === "ready") { + expect(plan.sourcePath).toBe("MyApp/MyAppApp.swift"); + } else { + expect(blockerCodes(plan)).toContain("ambiguous-entry-point"); + } + }, + ); + test("refuses mutation when JSON opaque-folder metadata is malformed", async () => { const root = await temporaryRoot("clerk-xcproj-direct-config-malformed-opaque-folder-"); await createIOSJSONFixture(root); @@ -944,6 +1160,53 @@ struct TemplateApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("preserves a JSON Compile Sources inclusion that overrides a target exclusion", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-inclusion-precedence-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["files", 0, "target-membership"], + ["MyApp", "SharedTarget"], + ); + project = applyXCProjValue( + project, + ["files", 0, "membership-exceptions"], + [ + { target: "SharedTarget", exclusions: ["MyAppApp.swift"] }, + { + "build-phase": "SharedTarget/compile-sources", + inclusions: ["MyAppApp.swift"], + }, + ], + ); + project = applyXCProjValue(project, ["targets", 1], { + name: "SharedTarget", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, project); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + test.each(["folder", "group"] as const)( "refuses mutation when another target's JSON %s uses an unresolved source-root path", async (kind) => { 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 4f3094201..bbed963c3 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 @@ -331,6 +331,42 @@ describe("missing iOS entitlements build settings", () => { ).toMatchObject({ status: "ready", blockers: [] }); }); + test("accepts unrelated explicit and resource-group references in Xcode JSON ownership", async () => { + const root = await temporaryRoot(); + await createIOSJSONFixture(root); + const path = xcprojPath(root); + let source = await readFile(path, "utf8"); + source = applyXCProjValue( + source, + ["targets", 0, "build-settings", "CODE_SIGN_ENTITLEMENTS"], + undefined, + ); + source = applyXCProjValue(source, ["files", 2], { + kind: "file-reference", + path: "README.md", + }); + source = applyXCProjValue(source, ["files", 3], { + kind: "variant-group", + name: "Localizable.strings", + children: [{ kind: "file-reference", path: "en.lproj/Localizable.strings" }], + }); + source = applyXCProjValue(source, ["files", 4], { + kind: "version-group", + path: "Model.xcdatamodeld", + children: [{ kind: "file-reference", path: "Model.xcdatamodel" }], + }); + await writeFile(path, source); + await rm(entitlementsPath(root)); + + expect( + await planIOSMissingEntitlementsSettings({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }), + ).toMatchObject({ status: "ready", blockers: [] }); + }); + test("fails closed on an unresolved other-target Xcode JSON xcconfig", async () => { const root = await makeXCProjMissingEntitlementsWithOtherTarget( "CODE_SIGN_ENTITLEMENTS = $(TESTS_ENTITLEMENTS_DIR)/MyAppTests.entitlements\n", 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 68c1c0f80..ad0b2e158 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -323,7 +323,7 @@ async function selectedXCProjSynchronizedRoot( malformed = true; return; } - const kind = typeof reference.kind === "string" ? reference.kind : "file"; + const kind = typeof reference.kind === "string" ? reference.kind : "file-reference"; const path = typeof reference.path === "string" ? reference.path : ""; if (kind === "group") { const groupDirectory = path ? xcprojReferencePath(parent, path, projectDirectory) : parent; @@ -989,7 +989,8 @@ async function xcprojDestinationOwnershipIsExclusive( } return; } - if (kind !== "file") { + if (kind === "variant-group" || kind === "version-group") return; + if (kind !== "file" && kind !== "file-reference") { complete = false; return; } diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index b6d1b178b..88db8a05a 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -297,6 +297,38 @@ describe("iOS Clerk SDK installer", () => { expect(await readFile(path)).toEqual(installedBytes); }); + test("blocks before adding clerk-ios beside an unattributed Xcode JSON Clerk product", async () => { + const root = await temporaryRoot("clerk-xcproj-unattributed-product-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + path, + applyXCProjValue( + await Bun.file(path).text(), + ["targets", 0, "package-product-members"], + [ + { + "product-name": "ClerkKit", + "build-phase": { "build-phase": "frameworks" }, + }, + ], + ), + ); + const before = await readFile(path); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "blocked", + blockers: [{ code: "unattributed-product" }], + }); + expect(await readFile(path)).toEqual(before); + }); + test("treats an empty Xcode JSON product platform filter as unrestricted", async () => { const root = await temporaryRoot("clerk-xcproj-empty-platforms-"); await createIOSJSONFixture(root); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 2ab55aaf5..4da1f1a8d 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -35,15 +35,20 @@ import { const APP_PRODUCT_TYPES = new Set(["application", "com.apple.product-type.application"]); const MAX_SOURCE_FILES = 2_500; const MAX_SOURCE_DEPTH = 24; -const SOURCE_IGNORES = new Set([ - ".build", - ".git", - ".swiftpm", - "build", - "Carthage", - "DerivedData", - "Pods", - "SourcePackages", +// Xcode excludes its own metadata directory, but otherwise synchronized folders +// may compile Swift sources below conventional dependency, build, and hidden +// directories. Traversal limits below provide the safety bound instead. +const SOURCE_DIRECTORY_IGNORES = new Set([".git"]); +// Xcode treats these directory packages as opaque resources rather than +// recursively discovering their Swift files as target sources. +const OPAQUE_SOURCE_DIRECTORY_EXTENSIONS = new Set([ + ".bundle", + ".docc", + ".lproj", + ".playground", + ".xcassets", + ".xcdatamodeld", + ".xcplaygroundpage", ]); function emptySwiftInspection(): IOSSwiftInspection { @@ -288,6 +293,7 @@ async function collectSwiftFiles( directory: string, groupRoot: string, included: (relativePath: string) => boolean, + explicitFileType: (relativePath: string) => string | undefined, files: Map, state: { complete: boolean }, depth = 0, @@ -317,12 +323,36 @@ async function collectSwiftFiles( const pathFromGroup = normalizedPath(relative(groupRoot, absolutePath).split(sep).join("/")); if (!included(pathFromGroup)) continue; if (entry.isDirectory()) { - if (!SOURCE_IGNORES.has(entry.name) && !entry.name.startsWith(".")) { - await collectSwiftFiles(root, absolutePath, groupRoot, included, files, state, depth + 1); + if (explicitFileType(pathFromGroup) !== undefined) { + // File-type overrides on directories have wrapper semantics that this + // source inventory does not model. Refuse ownership-sensitive writes. + state.complete = false; + continue; + } + if ( + !SOURCE_DIRECTORY_IGNORES.has(entry.name) && + !OPAQUE_SOURCE_DIRECTORY_EXTENSIONS.has(extname(entry.name).toLowerCase()) + ) { + await collectSwiftFiles( + root, + absolutePath, + groupRoot, + included, + explicitFileType, + files, + state, + depth + 1, + ); } - } else if (entry.isFile() && extname(entry.name) === ".swift") { + continue; + } + const override = explicitFileType(pathFromGroup); + const isSwiftSource = + override === "sourcecode.swift" || + (override === undefined && extname(entry.name).toLowerCase() === ".swift"); + if (entry.isFile() && isSwiftSource) { files.set(absolutePath, { absolutePath, relativePath: relativeIOSPath(root, absolutePath) }); - } else if (entry.isSymbolicLink() && extname(entry.name) === ".swift") { + } else if (entry.isSymbolicLink() && isSwiftSource) { state.complete = false; } } @@ -334,7 +364,24 @@ function folderMembership( resolveBuildPhase: ProjectBuildPhaseResolver, platform: IOSNativePlatform | undefined, state: { complete: boolean }, -): { member: boolean; included: (path: string) => boolean } { +): { + member: boolean; + included: (path: string) => boolean; + explicitFileType: (path: string) => string | undefined; +} { + const fileTypes = new Map(); + try { + const rawFileTypes = reference["file-types"]; + if (rawFileTypes !== undefined) { + for (const [path, rawType] of Object.entries(xcprojRecord(rawFileTypes))) { + const normalized = normalizedPath(path); + if (!normalized || fileTypes.has(normalized)) state.complete = false; + fileTypes.set(normalized, xcprojString(rawType)); + } + } + } catch { + state.complete = false; + } let opaqueFolders: string[]; try { opaqueFolders = @@ -422,11 +469,13 @@ function folderMembership( matchesPath(set, path) || [...set].some((candidate) => candidate.startsWith(`${path}/`)); return { member: defaultMember || inclusions.size > 0, + explicitFileType(path) { + return fileTypes.get(normalizedPath(path)); + }, included(path) { if (matchesPath(opaque, path)) return false; - const base = defaultMember - ? !matchesPath(exclusions, path) - : matchesPathOrIncludedDescendant(inclusions, path); + const explicitlyIncluded = matchesPathOrIncludedDescendant(inclusions, path); + const base = explicitlyIncluded || (defaultMember && !matchesPath(exclusions, path)); if (!base || !platform) return base; for (const [candidate, raw] of filters) { if (path !== candidate && !path.startsWith(`${candidate}/`)) continue; @@ -535,7 +584,15 @@ async function sourceFilesForTarget(options: { } const membership = folderMembership(reference, target, resolveBuildPhase, platform, state); if (membership.member) { - await collectSwiftFiles(root, directory, directory, membership.included, files, state); + await collectSwiftFiles( + root, + directory, + directory, + membership.included, + membership.explicitFileType, + files, + state, + ); } return; } @@ -544,7 +601,17 @@ async function sourceFilesForTarget(options: { state.complete = false; return; } - if (!path || extname(path) !== ".swift") return; + let explicitType: string | undefined; + try { + explicitType = reference.type === undefined ? undefined : xcprojString(reference.type); + } catch { + state.complete = false; + return; + } + const isSwiftSource = + explicitType === "sourcecode.swift" || + (explicitType === undefined && extname(path).toLowerCase() === ".swift"); + if (!path || !isSwiftSource) return; if (!fileBelongsToSources(reference, target, resolveBuildPhase, platform, state)) return; const absolutePath = sourceReferencePath(projectDirectory, parent, path); if (!absolutePath || !(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { diff --git a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts index 247dd0761..13432270b 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-install-sdk.ts @@ -402,9 +402,17 @@ export async function prepareXCProjSDKInstall( ); } let selectedPackage = packageScan.verified[0]; + const membersResult = productMembers(selected.target); + if (!Array.isArray(membersResult)) return membersResult; let candidate = parsed.source; const actions: string[] = []; if (!selectedPackage) { + if (membersResult.some((member) => member.packageIdentity === undefined)) { + return blocked( + "unattributed-product", + "The selected target already links a Clerk product without identifying which Swift package supplies it, so Clerk cannot add another package safely.", + ); + } if (packageScan.unsafeLocalIdentity) { return blocked( "external-path", @@ -435,8 +443,6 @@ export async function prepareXCProjSDKInstall( if (compatibility) return compatibility; } - const membersResult = productMembers(selected.target); - if (!Array.isArray(membersResult)) return membersResult; const memberBlocker = validateProductMembers( membersResult, selectedPackage, diff --git a/packages/cli-core/src/commands/init/ios/xcproj.test.ts b/packages/cli-core/src/commands/init/ios/xcproj.test.ts index ffcc27fd6..30a514878 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.test.ts @@ -134,6 +134,20 @@ describe("parseXCProjSource", () => { ); }); + test("accepts and preserves a null Products group reference", () => { + const source = XCODE_GENERATED_PROJECT.replace( + ' "localizations": {', + ' "products-group": null,\n "localizations": {', + ); + + const parsed = parseXCProjSource(source); + expect(parsed.root["products-group"]).toBeNull(); + expect(xcprojTargets(parsed.root)).toHaveLength(1); + + const edited = applyXCProjValue(source, ["organization"], "Example"); + expect(parseXCProjSource(edited).root["products-group"]).toBeNull(); + }); + test("rejects malformed component-array configuration file references", () => { const source = XCODE_GENERATED_PROJECT.replace( '{ "anchor": "App", "relative-path": "Config.xcconfig" }', diff --git a/packages/cli-core/src/commands/init/ios/xcproj.ts b/packages/cli-core/src/commands/init/ios/xcproj.ts index 3b0742d98..06d965165 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.ts @@ -428,6 +428,7 @@ function validateRoot(root: XCProjRecord): void { } if ( root["products-group"] !== undefined && + root["products-group"] !== null && typeof root["products-group"] !== "string" && !Array.isArray(root["products-group"]) ) { From 8047e0382d2b91ba6a5cc2ef250b0fe283629f72 Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 22 Sep 2026 11:33:59 -0400 Subject: [PATCH 09/10] fix(init): handle Xcode JSON format variants --- bun.lock | 3 ++ packages/cli-core/package.json | 1 + .../src/commands/doctor/index.test.ts | 1 + .../src/commands/init/ios/inspect.test.ts | 25 +++++++++++++ .../cli-core/src/commands/init/ios/inspect.ts | 15 ++++++-- .../src/commands/init/ios/install-sdk.test.ts | 19 ++++++++++ .../cli-core/src/commands/init/ios/types.ts | 1 + .../src/commands/init/ios/xcproj.test.ts | 36 +++++++++++++++++++ .../cli-core/src/commands/init/ios/xcproj.ts | 19 ++++++++-- 9 files changed, 116 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index 73f051100..579f69266 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "commander": "^15.0.0", "env-paths": "^4.0.0", "external-editor": "^3.1.0", + "json5": "^2.2.3", "jsonc-parser": "^3.3.1", "magicast": "^0.5.3", "semver": "^7.8.5", @@ -501,6 +502,8 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index fc988d21e..fdfdf3cd1 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -26,6 +26,7 @@ "commander": "^15.0.0", "env-paths": "^4.0.0", "external-editor": "^3.1.0", + "json5": "^2.2.3", "jsonc-parser": "^3.3.1", "magicast": "^0.5.3", "semver": "^7.8.5", diff --git a/packages/cli-core/src/commands/doctor/index.test.ts b/packages/cli-core/src/commands/doctor/index.test.ts index 553154c84..5ff2b84b3 100644 --- a/packages/cli-core/src/commands/doctor/index.test.ts +++ b/packages/cli-core/src/commands/doctor/index.test.ts @@ -132,6 +132,7 @@ describe("Apple-native framework routing", () => { test.each([ ["xcode.malformed-project", "Could not parse App.xcodeproj/project.pbxproj."], + ["xcode.noncanonical-json5", "App.xcodeproj/project.xcproj needs canonicalization."], ["xcode.missing-project-file", "App.xcodeproj does not contain project.pbxproj."], ] as const)("fails native inspection for %s", async (code, message) => { const failedInspection = { 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 d765b2ae7..39cf05ddc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -884,6 +884,31 @@ describe("inspectIOSProject", () => { expect(inspection.diagnostics).toEqual([]); }); + test("reports valid noncanonical JSON5 with safe canonicalization guidance", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-json5-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const source = await readFile(projectPath, "utf8"); + await Bun.write(projectPath, source.replace('"development": "en"', "development: 'en'")); + + const inspection = await inspectIOSProject(root); + + expect(inspection.selection.state).toBe("none"); + expect(inspection.appTargets).toEqual([]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ + code: "xcode.noncanonical-json5", + severity: "error", + message: expect.stringContaining("valid JSON5"), + remedy: expect.stringContaining("xcprojformatter --update"), + }), + ); + expect( + inspection.diagnostics.some((diagnostic) => diagnostic.code === "xcode.malformed-project"), + ).toBe(false); + }); + test("accepts explicit file references and localized variant groups during source inspection", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-reference-kinds-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 94c981eb0..a47d9ea0a 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -33,7 +33,7 @@ import { } from "./pbx.ts"; import { inspectSwiftSources } from "./swift.ts"; import { inspectXCProjProject } from "./xcproj-inspect.ts"; -import { MAX_XCPROJ_BYTES, parseXCProjSource } from "./xcproj.ts"; +import { MAX_XCPROJ_BYTES, parseXCProjSource, XCProjError } from "./xcproj.ts"; import type { IOSAppTarget, IOSClerkPackageState, @@ -1119,7 +1119,18 @@ async function parseProject( requestedTarget, requestedPlatform, }); - } catch { + } catch (error) { + if (error instanceof XCProjError && error.code === "noncanonical-json5") { + diagnostics.push({ + code: "xcode.noncanonical-json5", + severity: "error", + message: `${documentRelativePath} uses valid JSON5 syntax that must be canonicalized before Clerk can inspect or modify it.`, + remedy: + "Run `xcprojformatter --update `, review the resulting project diff, then retry.", + evidence: [{ path: documentRelativePath }], + }); + return { inspection: emptyInspection, appTargets: [], appTargetCandidates: [], diagnostics }; + } diagnostics.push({ code: "xcode.malformed-project", severity: "error", diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts index 88db8a05a..b726fd478 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.test.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.test.ts @@ -297,6 +297,25 @@ describe("iOS Clerk SDK installer", () => { expect(await readFile(path)).toEqual(installedBytes); }); + test("leaves valid noncanonical JSON5 byte-identical instead of attempting a package edit", async () => { + const root = await temporaryRoot("clerk-xcproj-json5-install-"); + await createIOSJSONFixture(root); + const path = join(root, "MyApp.xcodeproj", "project.xcproj"); + const source = await readFile(path, "utf8"); + await Bun.write(path, source.replace('"development": "en"', "development: 'en'")); + const before = await readFile(path); + + const plan = await planIOSSDKInstall({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(await applyIOSSDKInstall(plan)).toMatchObject({ status: "blocked" }); + expect(await readFile(path)).toEqual(before); + }); + test("blocks before adding clerk-ios beside an unattributed Xcode JSON Clerk product", async () => { const root = await temporaryRoot("clerk-xcproj-unattributed-product-"); await createIOSJSONFixture(root); diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 26d13578d..c5dbd9c2c 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -14,6 +14,7 @@ export interface IOSDiagnostic { code: | "xcode.no-project" | "xcode.malformed-project" + | "xcode.noncanonical-json5" | "xcode.missing-project-file" | "xcode.dangling-reference" | "xcode.no-ios-app-target" diff --git a/packages/cli-core/src/commands/init/ios/xcproj.test.ts b/packages/cli-core/src/commands/init/ios/xcproj.test.ts index 30a514878..884ca196e 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.test.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.test.ts @@ -148,6 +148,31 @@ describe("parseXCProjSource", () => { expect(parseXCProjSource(edited).root["products-group"]).toBeNull(); }); + test("accepts and preserves object components in a Products group reference", () => { + const source = XCODE_GENERATED_PROJECT.replace( + ' "localizations": {', + ' "products-group": [ { "name": "Build/Products" } ],\n "localizations": {', + ); + + const parsed = parseXCProjSource(source); + expect(parsed.root["products-group"]).toEqual([{ name: "Build/Products" }]); + + const edited = applyXCProjValue(source, ["organization"], "Example"); + expect(parseXCProjSource(edited).root["products-group"]).toEqual([{ name: "Build/Products" }]); + expect(edited).toContain('"products-group": [ { "name": "Build/Products" } ]'); + }); + + test("rejects malformed object components in a Products group reference", () => { + const source = XCODE_GENERATED_PROJECT.replace( + ' "localizations": {', + ' "products-group": [ { "path": "Build/Products" } ],\n "localizations": {', + ); + + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "invalid-schema" }), + ); + }); + test("rejects malformed component-array configuration file references", () => { const source = XCODE_GENERATED_PROJECT.replace( '{ "anchor": "App", "relative-path": "Config.xcconfig" }', @@ -181,6 +206,17 @@ describe("parseXCProjSource", () => { ); }); + test("distinguishes valid noncanonical JSON5 without permitting edits", () => { + const source = XCODE_GENERATED_PROJECT.replace('"development": "en"', "development: 'en'"); + + expect(() => parseXCProjSource(source)).toThrow( + expect.objectContaining({ code: "noncanonical-json5" }), + ); + expect(() => applyXCProjValue(source, ["organization"], "Example")).toThrow( + expect.objectContaining({ code: "noncanonical-json5" }), + ); + }); + test("fails closed on required capabilities", () => { const source = XCODE_GENERATED_PROJECT.replace( "{\n", diff --git a/packages/cli-core/src/commands/init/ios/xcproj.ts b/packages/cli-core/src/commands/init/ios/xcproj.ts index 06d965165..23e84e138 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj.ts @@ -7,6 +7,7 @@ import { type Node, type ParseError, } from "jsonc-parser"; +import JSON5 from "json5"; export const MAX_XCPROJ_BYTES = 15_000_000; @@ -16,6 +17,7 @@ export type XCProjErrorCode = | "too-large" | "invalid-utf8" | "invalid-syntax" + | "noncanonical-json5" | "duplicate-key" | "invalid-schema" | "unsupported-capability" @@ -434,7 +436,12 @@ function validateRoot(root: XCProjRecord): void { ) { schemaError(); } - if (Array.isArray(root["products-group"])) xcprojStringArray(root["products-group"]); + if (Array.isArray(root["products-group"])) { + for (const component of xcprojArray(root["products-group"])) { + if (typeof component === "string") continue; + xcprojString(xcprojRecord(component).name); + } + } } function validateNoDuplicateKeys(node: Node): void { @@ -485,7 +492,15 @@ export function parseXCProjSource( allowEmptyContent: false, }); if (!tree || errors.length > 0) { - throw new XCProjError("invalid-syntax", "project.xcproj is not valid canonical Xcode JSON."); + try { + JSON5.parse(text); + } catch { + throw new XCProjError("invalid-syntax", "project.xcproj is not valid JSON5."); + } + throw new XCProjError( + "noncanonical-json5", + "project.xcproj uses valid JSON5 syntax that must be canonicalized before Clerk can inspect or modify it.", + ); } if (tree.type !== "object") schemaError(); validateNoDuplicateKeys(tree); From 573b213534b6003ba914890ced4e597ad1cf122d Mon Sep 17 00:00:00 2001 From: seanperez Date: Tue, 22 Sep 2026 12:01:15 -0400 Subject: [PATCH 10/10] fix(init): align Xcode JSON folder semantics --- .../commands/init/ios/direct-config.test.ts | 69 ++++++++++ .../src/commands/init/ios/inspect.test.ts | 124 +++++++++++++++++- .../src/commands/init/ios/xcproj-inspect.ts | 42 ++++-- 3 files changed, 221 insertions(+), 14 deletions(-) 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 73b8dda0b..9f689ec86 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 @@ -1160,6 +1160,75 @@ struct GeneratedApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("refuses an entry source toggled into a non-default JSON folder target", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-target-exception-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let project = await readFile(projectPath, "utf8"); + project = applyXCProjValue( + project, + ["files", 0, "membership-exceptions"], + [{ target: "SharedTarget", exclusions: ["MyAppApp.swift"] }], + ); + project = applyXCProjValue(project, ["targets", 1], { + name: "SharedTarget", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.SharedTarget", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await writeFile(projectPath, project); + const before = await readFile(appSourcePath(root)); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("shared-source"); + expect(await readFile(appSourcePath(root))).toEqual(before); + }); + + test("follows a JSON file platform override inside an excluded folder", async () => { + const root = await temporaryRoot("clerk-xcproj-direct-config-platform-override-"); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const nestedDirectory = join(root, "MyApp", "Nested"); + await mkdir(nestedDirectory, { recursive: true }); + await rename(join(root, "MyApp", "MyAppApp.swift"), join(nestedDirectory, "MyAppApp.swift")); + await writeFile( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "membership-exceptions"], + [ + { + target: "MyApp", + platforms: { Nested: ["macos"], "Nested/MyAppApp.swift": ["ios"] }, + }, + ], + ), + ); + + const plan = await planIOSDirectConfig({ + root, + projectPath: "MyApp.xcodeproj", + targetId: "C1E000000000000000000001", + }); + + expect(plan).toMatchObject({ + status: "ready", + sourcePath: "MyApp/Nested/MyAppApp.swift", + }); + }); + test("preserves a JSON Compile Sources inclusion that overrides a target exclusion", async () => { const root = await temporaryRoot("clerk-xcproj-direct-config-inclusion-precedence-"); await createIOSJSONFixture(root); 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 39cf05ddc..c9d471351 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; -import { lstat, mkdtemp, mkdir, readFile, rm, symlink, truncate } from "node:fs/promises"; +import { lstat, mkdtemp, mkdir, readFile, rename, rm, symlink, truncate } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { tmpdir } from "node:os"; import { discoverIOSContainers, discoverLocalIOSProjects, inspectWorkspace } from "./discovery.ts"; @@ -1057,6 +1057,128 @@ describe("inspectIOSProject", () => { expect(owners.every((membership) => membership.complete)).toBe(true); }); + test("derives JSON folder exception polarity from default target membership", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-exception-polarity-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + let source = await readFile(projectPath, "utf8"); + source = applyXCProjValue( + source, + ["files", 0, "membership-exceptions"], + [{ target: "OtherApp", exclusions: ["MyAppApp.swift"] }], + ); + source = applyXCProjValue(source, ["targets", 1], { + name: "OtherApp", + id: "C1E000000000000000000099", + "product-type": "application", + "build-phases": ["compile-sources"], + "build-settings": { + DEVELOPMENT_TEAM: "ABCDE12345", + IPHONEOS_DEPLOYMENT_TARGET: "17.0", + PRODUCT_BUNDLE_IDENTIFIER: "com.example.OtherApp", + SUPPORTED_PLATFORMS: "iphoneos iphonesimulator", + }, + }); + await Bun.write(projectPath, source); + + const memberships = await inspectIOSSourceMembership(root); + const otherApp = memberships.find( + (membership) => membership.targetId === "C1E000000000000000000099", + ); + + expect(otherApp).toMatchObject({ complete: true }); + expect(otherApp?.files.map((file) => file.relativePath)).toContain("MyApp/MyAppApp.swift"); + }); + + test("treats an inclusions-spelled exception as removal from a default JSON folder target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-default-member-exception-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "membership-exceptions"], + [{ target: "MyApp", inclusions: ["MyAppApp.swift"] }], + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 1, + entryPoints: [], + }); + }); + + test.each(["parent-first", "child-first"] as const)( + "uses the most-specific JSON platform filter regardless of %s ordering", + async (order) => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-platform-filter-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const nestedDirectory = join(root, "MyApp", "Nested"); + await mkdir(nestedDirectory, { recursive: true }); + await rename(join(root, "MyApp", "MyAppApp.swift"), join(nestedDirectory, "MyAppApp.swift")); + await Bun.write(join(nestedDirectory, "NotForIOS.swift"), "struct NotForIOS {}\n"); + const filters = + order === "parent-first" + ? { Nested: ["macos"], "Nested/MyAppApp.swift": ["ios"] } + : { "Nested/MyAppApp.swift": ["ios"], Nested: ["macos"] }; + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "membership-exceptions"], + [{ target: "MyApp", platforms: filters }], + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 2, + entryPoints: [{ path: "MyApp/Nested/MyAppApp.swift" }], + }); + }, + ); + + test("lets a specific JSON file filter opt out of its folder's platform", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-platform-opt-out-")); + temporaryDirectories.push(root); + await createIOSJSONFixture(root); + const projectPath = join(root, "MyApp.xcodeproj", "project.xcproj"); + const nestedDirectory = join(root, "MyApp", "Nested"); + await mkdir(nestedDirectory, { recursive: true }); + await rename(join(root, "MyApp", "MyAppApp.swift"), join(nestedDirectory, "MyAppApp.swift")); + await Bun.write( + projectPath, + applyXCProjValue( + await readFile(projectPath, "utf8"), + ["files", 0, "membership-exceptions"], + [ + { + target: "MyApp", + platforms: { Nested: ["ios"], "Nested/MyAppApp.swift": ["macos"] }, + }, + ], + ), + ); + + const inspection = await inspectIOSProject(root); + + expect(inspection.appTargets[0]?.swift).toMatchObject({ + evidenceComplete: true, + sourceFilesScanned: 1, + entryPoints: [], + }); + }); + test("accepts attribute-only JSON folder exceptions and applies their platform filters", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-xcproj-attribute-exception-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts index 4da1f1a8d..082d3db42 100644 --- a/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts +++ b/packages/cli-core/src/commands/init/ios/xcproj-inspect.ts @@ -292,7 +292,7 @@ async function collectSwiftFiles( root: string, directory: string, groupRoot: string, - included: (relativePath: string) => boolean, + included: (relativePath: string, kind: "file" | "directory") => boolean, explicitFileType: (relativePath: string) => string | undefined, files: Map, state: { complete: boolean }, @@ -321,8 +321,8 @@ async function collectSwiftFiles( } const absolutePath = resolve(directory, entry.name); const pathFromGroup = normalizedPath(relative(groupRoot, absolutePath).split(sep).join("/")); - if (!included(pathFromGroup)) continue; if (entry.isDirectory()) { + if (!included(pathFromGroup, "directory")) continue; if (explicitFileType(pathFromGroup) !== undefined) { // File-type overrides on directories have wrapper semantics that this // source inventory does not model. Refuse ownership-sensitive writes. @@ -346,6 +346,7 @@ async function collectSwiftFiles( } continue; } + if (!included(pathFromGroup, "file")) continue; const override = explicitFileType(pathFromGroup); const isSwiftSource = override === "sourcecode.swift" || @@ -366,7 +367,7 @@ function folderMembership( state: { complete: boolean }, ): { member: boolean; - included: (path: string) => boolean; + included: (path: string, kind: "file" | "directory") => boolean; explicitFileType: (path: string) => string | undefined; } { const fileTypes = new Map(); @@ -443,15 +444,19 @@ function folderMembership( const hasInclusions = Object.hasOwn(exception, "inclusions"); const hasExclusions = Object.hasOwn(exception, "exclusions"); - if ((hasInclusions && hasExclusions) || (buildPhaseException && hasExclusions)) { + if (hasInclusions && hasExclusions) { state.complete = false; continue; } if (hasInclusions || hasExclusions) { + // Xcode derives an exception's meaning from the folder's default + // membership. The serialized key records archive intent, not polarity. + // Build-phase exceptions always add files to that phase. + const semanticExceptions = buildPhaseException || !defaultMember ? inclusions : exclusions; for (const path of xcprojStringArray( exception[hasInclusions ? "inclusions" : "exclusions"], )) { - (hasInclusions ? inclusions : exclusions).add(normalizedPath(path)); + semanticExceptions.add(normalizedPath(path)); } } if (exception.platforms !== undefined) { @@ -467,23 +472,34 @@ function folderMembership( [...set].some((candidate) => path === candidate || path.startsWith(`${candidate}/`)); const matchesPathOrIncludedDescendant = (set: Set, path: string): boolean => matchesPath(set, path) || [...set].some((candidate) => candidate.startsWith(`${path}/`)); + const mostSpecificFilter = (path: string): [string, unknown] | undefined => { + let match: [string, unknown] | undefined; + for (const entry of filters) { + const [candidate] = entry; + if (path !== candidate && !path.startsWith(`${candidate}/`)) continue; + if (!match || candidate.length > match[0].length) match = entry; + } + return match; + }; + const hasDescendantFilter = (path: string): boolean => + [...filters].some(([candidate]) => candidate.startsWith(`${path}/`)); return { member: defaultMember || inclusions.size > 0, explicitFileType(path) { return fileTypes.get(normalizedPath(path)); }, - included(path) { + included(path, kind) { if (matchesPath(opaque, path)) return false; const explicitlyIncluded = matchesPathOrIncludedDescendant(inclusions, path); const base = explicitlyIncluded || (defaultMember && !matchesPath(exclusions, path)); if (!base || !platform) return base; - for (const [candidate, raw] of filters) { - if (path !== candidate && !path.startsWith(`${candidate}/`)) continue; - const result = platformFiltersApply(raw, platform); - state.complete &&= result.complete; - return result.applies; - } - return true; + const filter = mostSpecificFilter(path); + if (!filter) return true; + const result = platformFiltersApply(filter[1], platform); + state.complete &&= result.complete; + // A more-specific descendant may override a filtered directory, so keep + // traversing until the filter can be evaluated for the concrete file. + return result.applies || (kind === "directory" && hasDescendantFilter(path)); }, }; }