Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/xcode-json-project-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Support inspecting, configuring, and verifying Apple projects that use Xcode's JSON project format.
8 changes: 7 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"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",
"yaml": "^2.9.0"
Expand Down
1 change: 1 addition & 0 deletions packages/cli-core/src/commands/doctor/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
6 changes: 5 additions & 1 deletion packages/cli-core/src/commands/init/ios/apple-entitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down
62 changes: 60 additions & 2 deletions packages/cli-core/src/commands/init/ios/apply-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
convertIOSFixtureToSynchronizedMissingEntitlements,
convertIOSFixtureToSynchronizedRoot,
createIOSFixture,
createIOSJSONFixture,
IOS_FIXTURE_IDS,
treeDigest,
} from "./test-helpers.ts";
Expand Down Expand Up @@ -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<void> {
const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj");
const project = await Bun.file(projectPath).text();
Expand Down Expand Up @@ -127,15 +177,14 @@ async function auditCurrentNativeFixture(root: string) {
return runIOSDoctorChecks(doctorContext(), { root, target: "MyApp" }, dependencies);
}

function expectAutomatedDoctorChecksToPass(
function expectCanonicalIOSDoctorChecksToPass(
results: Awaited<ReturnType<typeof auditCurrentNativeFixture>>["results"],
): void {
for (const expectedName of [
"iOS: Install Clerk's iOS SDK for the selected target",
"iOS: Configure Clerk with a publishable key",
"iOS: Inject Clerk into the SwiftUI environment",
"iOS: Add Clerk's associated domain",
"macOS: Allow outgoing network access",
"iOS: Linked development key",
"iOS: Native Application",
]) {
Expand All @@ -153,6 +202,15 @@ function expectAutomatedDoctorChecksToPass(
});
}

function expectAutomatedDoctorChecksToPass(
results: Awaited<ReturnType<typeof auditCurrentNativeFixture>>["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(),
Expand Down
49 changes: 30 additions & 19 deletions packages/cli-core/src/commands/init/ios/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`);
Expand All @@ -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}`);
Expand All @@ -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)) {
Expand All @@ -696,24 +707,24 @@ export async function applyIOSLocalSetup(
);
const alreadyPreviewedPaths = new Set<string>();
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}`);
}
Expand Down Expand Up @@ -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.
*/
Expand Down
65 changes: 65 additions & 0 deletions packages/cli-core/src/commands/init/ios/associated-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading