Skip to content

Commit e9e8e91

Browse files
committed
feat(init): reconcile native iOS configuration
1 parent 7074d10 commit e9e8e91

58 files changed

Lines changed: 15936 additions & 341 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/calm-apples-inspect.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": minor
3+
---
4+
5+
Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten.

packages/cli-core/src/cli-program.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { maybeNotifyUpdate } from "./lib/update-check.ts";
4848
import { CURRENT_VERSION } from "./lib/version.ts";
4949
import { registerExtras } from "@clerk/cli-extras";
5050
import {
51+
discardCommandTelemetry,
5152
finalizeAndSendTelemetry,
5253
startCommandTelemetry,
5354
telemetryResultForError,
@@ -61,6 +62,18 @@ export type Program = Command<[], { inputJson?: string; mode?: string; verbose?:
6162

6263
type CommandRegistrant = (program: Program) => void;
6364

65+
/**
66+
* `init --dry-run` promises an invocation-wide read-only boundary. Keep the
67+
* check here, outside the init action, so global hooks cannot send telemetry,
68+
* fetch an update, or persist their caches around an otherwise read-only run.
69+
*/
70+
function isReadOnlyInitDryRun(actionCommand: {
71+
name(): string;
72+
getOptionValue(key: string): unknown;
73+
}): boolean {
74+
return actionCommand.name() === "init" && actionCommand.getOptionValue("dryRun") === true;
75+
}
76+
6477
const registrants: CommandRegistrant[] = [
6578
registerInit,
6679
registerAuth,
@@ -109,8 +122,15 @@ export function createProgram(): Program {
109122
.option("--verbose", "Show detailed output (enables debug messages)") as Program;
110123

111124
program.hook("preAction", async (_thisCommand, actionCommand) => {
125+
const readOnlyInitDryRun = isReadOnlyInitDryRun(actionCommand);
112126
// First so hook-time failures (e.g. invalid --mode) still produce an event.
113-
startCommandTelemetry(actionCommand);
127+
// A read-only iOS inspection is the exception: its boundary covers global
128+
// command hooks as well as the init action itself.
129+
if (readOnlyInitDryRun) {
130+
discardCommandTelemetry();
131+
} else {
132+
startCommandTelemetry(actionCommand);
133+
}
114134
// Reset log level at the start of each command invocation so a previous
115135
// --verbose doesn't leak into subsequent runs.
116136
setLogLevel("info");
@@ -125,6 +145,11 @@ export function createProgram(): Program {
125145
setMode(opts.mode as Mode);
126146
}
127147

148+
// Environment selection only affects remote Clerk operations. Avoid even
149+
// reading or rendering persisted CLI environment state for this local-only
150+
// inspection path.
151+
if (readOnlyInitDryRun) return;
152+
128153
// Initialize the active environment from persisted config
129154
const envName = await getEnvironment();
130155
if (envName && isValidEnv(envName)) {
@@ -150,6 +175,7 @@ export function createProgram(): Program {
150175
// Show update notification after each command, except for commands that
151176
// already perform their own version check (doctor, update).
152177
program.hook("postAction", async (_thisCommand, actionCommand) => {
178+
if (isReadOnlyInitDryRun(actionCommand)) return;
153179
const cmdName = actionCommand.name();
154180
if (cmdName === "doctor" || cmdName === "update") return;
155181
await maybeNotifyUpdate(CURRENT_VERSION);

packages/cli-core/src/commands/deploy/index.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ const mockPatchInstanceConfig = mock();
2727
const mockFetchInstanceConfig = mock();
2828
const mockFetchInstanceConfigSchema = mock();
2929
const mockFetchApplication = mock();
30+
const mockListIOSApplications = mock();
31+
const mockGetNativeSettings = mock();
3032
const mockListApplicationDomains = mock();
3133
const mockCreateProductionInstance = mock();
3234
const mockGetApplicationDomainStatus = mock();
@@ -49,6 +51,8 @@ mock.module("../../lib/plapi.ts", () => ({
4951
fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args),
5052
fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args),
5153
fetchApplication: (...args: unknown[]) => mockFetchApplication(...args),
54+
listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args),
55+
getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args),
5256
listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args),
5357
createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args),
5458
getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args),
@@ -228,6 +232,8 @@ describe("deploy", () => {
228232
mockGetApplicationDomainStatus.mockResolvedValue(
229233
domainStatus({ status: "complete", dns: true, ssl: true, mail: true }),
230234
);
235+
mockListIOSApplications.mockResolvedValue([]);
236+
mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true });
231237
stubCreateProductionInstance();
232238
mockTriggerApplicationDomainDNSCheck.mockResolvedValue(
233239
domainStatus({ status: "complete", dns: true, ssl: true, mail: true }),
@@ -261,6 +267,8 @@ describe("deploy", () => {
261267
mockFetchInstanceConfig.mockReset();
262268
mockFetchInstanceConfigSchema.mockReset();
263269
mockFetchApplication.mockReset();
270+
mockListIOSApplications.mockReset();
271+
mockGetNativeSettings.mockReset();
264272
mockListApplicationDomains.mockReset();
265273
mockCreateProductionInstance.mockReset();
266274
mockGetApplicationDomainStatus.mockReset();
@@ -1253,6 +1261,213 @@ describe("deploy", () => {
12531261
expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback");
12541262
});
12551263

1264+
test("skips Apple web credential prompts for an exact native-only production registration", async () => {
1265+
await linkedProject({
1266+
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
1267+
});
1268+
mockLiveProduction({
1269+
instanceId: "ins_prod_native_apple",
1270+
developmentConfig: {
1271+
connection_oauth_apple: {
1272+
enabled: true,
1273+
authenticatable: true,
1274+
bundle_id: "com.example.native",
1275+
},
1276+
},
1277+
productionConfig: {
1278+
connection_oauth_apple: {
1279+
enabled: true,
1280+
authenticatable: true,
1281+
bundle_id: "com.example.native",
1282+
},
1283+
},
1284+
});
1285+
mockListIOSApplications.mockResolvedValueOnce([
1286+
{
1287+
object: "ios_application",
1288+
id: "ios_native",
1289+
app_id_prefix: "ABCDE12345",
1290+
bundle_id: "com.example.native",
1291+
created_at: 1,
1292+
updated_at: 1,
1293+
},
1294+
]);
1295+
mockIsAgent.mockReturnValue(false);
1296+
1297+
await runDeploy({});
1298+
1299+
expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple");
1300+
expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple");
1301+
expect(mockSelect).not.toHaveBeenCalled();
1302+
expect(mockInput).not.toHaveBeenCalled();
1303+
expect(mockPassword).not.toHaveBeenCalled();
1304+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1305+
const err = stripAnsi(captured.err);
1306+
expect(err).toContain("No deploy actions remain.");
1307+
expect(err).toContain("OAuth Apple");
1308+
expect(err).not.toContain("Configure Apple OAuth for production");
1309+
});
1310+
1311+
test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => {
1312+
await linkedProject({
1313+
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
1314+
});
1315+
mockLiveProduction({
1316+
instanceId: "ins_prod_native_apple",
1317+
developmentConfig: {
1318+
connection_oauth_apple: {
1319+
enabled: true,
1320+
authenticatable: true,
1321+
bundle_id: "com.example.native",
1322+
},
1323+
},
1324+
productionConfig: {
1325+
connection_oauth_apple: {
1326+
enabled: true,
1327+
authenticatable: true,
1328+
bundle_id: "com.example.native",
1329+
},
1330+
},
1331+
});
1332+
mockListIOSApplications.mockResolvedValueOnce([
1333+
{
1334+
object: "ios_application",
1335+
id: "ios_other",
1336+
app_id_prefix: "OTHER12345",
1337+
bundle_id: "com.example.other",
1338+
created_at: 1,
1339+
updated_at: 1,
1340+
},
1341+
]);
1342+
mockIsAgent.mockReturnValue(false);
1343+
1344+
await expect(runDeploy({})).rejects.toThrow(
1345+
"the production instance does not have an exact iOS Native Application registration for that Bundle ID",
1346+
);
1347+
1348+
expect(mockSelect).not.toHaveBeenCalled();
1349+
expect(mockInput).not.toHaveBeenCalled();
1350+
expect(mockPassword).not.toHaveBeenCalled();
1351+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1352+
expect(stripAnsi(captured.err)).toContain("Failed");
1353+
});
1354+
1355+
test("preserves Ctrl-C while verifying a native-only Apple registration", async () => {
1356+
await linkedProject({
1357+
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
1358+
});
1359+
mockLiveProduction({
1360+
instanceId: "ins_prod_native_apple",
1361+
developmentConfig: {
1362+
connection_oauth_apple: {
1363+
enabled: true,
1364+
authenticatable: true,
1365+
bundle_id: "com.example.native",
1366+
},
1367+
},
1368+
productionConfig: {
1369+
connection_oauth_apple: {
1370+
enabled: true,
1371+
authenticatable: true,
1372+
bundle_id: "com.example.native",
1373+
},
1374+
},
1375+
});
1376+
mockListIOSApplications
1377+
.mockRejectedValueOnce(new Error("native status endpoint unavailable"))
1378+
.mockRejectedValueOnce(promptExitError());
1379+
mockIsAgent.mockReturnValue(false);
1380+
1381+
await expect(runDeploy({})).rejects.toMatchObject({ exitCode: EXIT_CODE.SIGINT });
1382+
1383+
expect(mockSelect).not.toHaveBeenCalled();
1384+
expect(mockInput).not.toHaveBeenCalled();
1385+
expect(mockPassword).not.toHaveBeenCalled();
1386+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1387+
expect(stripAnsi(captured.err)).toContain("Paused");
1388+
});
1389+
1390+
test("refuses native-only Apple when production Native API is disabled", async () => {
1391+
await linkedProject({
1392+
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
1393+
});
1394+
mockLiveProduction({
1395+
instanceId: "ins_prod_native_apple",
1396+
developmentConfig: {
1397+
connection_oauth_apple: {
1398+
enabled: true,
1399+
authenticatable: true,
1400+
bundle_id: "com.example.native",
1401+
},
1402+
},
1403+
productionConfig: {
1404+
connection_oauth_apple: {
1405+
enabled: true,
1406+
authenticatable: true,
1407+
bundle_id: "com.example.native",
1408+
},
1409+
},
1410+
});
1411+
mockListIOSApplications.mockResolvedValue([
1412+
{
1413+
object: "ios_application",
1414+
id: "ios_native",
1415+
app_id_prefix: "ABCDE12345",
1416+
bundle_id: "com.example.native",
1417+
created_at: 1,
1418+
updated_at: 1,
1419+
},
1420+
]);
1421+
mockGetNativeSettings.mockResolvedValue({
1422+
object: "native_settings",
1423+
api_enabled: false,
1424+
});
1425+
mockIsAgent.mockReturnValue(false);
1426+
1427+
await expect(runDeploy({})).rejects.toThrow(
1428+
"Enable Native API at https://dashboard.clerk.com/~/native-applications",
1429+
);
1430+
1431+
expect(mockSelect).not.toHaveBeenCalled();
1432+
expect(mockInput).not.toHaveBeenCalled();
1433+
expect(mockPassword).not.toHaveBeenCalled();
1434+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1435+
});
1436+
1437+
test("refuses native-only Apple that is not explicitly authenticatable", async () => {
1438+
await linkedProject({
1439+
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
1440+
});
1441+
mockLiveProduction({
1442+
instanceId: "ins_prod_native_apple",
1443+
developmentConfig: {
1444+
connection_oauth_apple: {
1445+
enabled: true,
1446+
authenticatable: true,
1447+
bundle_id: "com.example.native",
1448+
},
1449+
},
1450+
productionConfig: {
1451+
connection_oauth_apple: {
1452+
enabled: true,
1453+
bundle_id: "com.example.native",
1454+
},
1455+
},
1456+
});
1457+
mockIsAgent.mockReturnValue(false);
1458+
1459+
await expect(runDeploy({})).rejects.toThrow(
1460+
"Apple is not explicitly enabled for authentication on the production instance",
1461+
);
1462+
1463+
expect(mockListIOSApplications).not.toHaveBeenCalled();
1464+
expect(mockGetNativeSettings).not.toHaveBeenCalled();
1465+
expect(mockSelect).not.toHaveBeenCalled();
1466+
expect(mockInput).not.toHaveBeenCalled();
1467+
expect(mockPassword).not.toHaveBeenCalled();
1468+
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
1469+
});
1470+
12561471
test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => {
12571472
await linkedProject({
12581473
instances: { development: "ins_dev_123", production: "ins_prod_apple" },
@@ -1306,6 +1521,8 @@ describe("deploy", () => {
13061521
"-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n",
13071522
},
13081523
});
1524+
expect(mockListIOSApplications).not.toHaveBeenCalled();
1525+
expect(mockGetNativeSettings).not.toHaveBeenCalled();
13091526
const p8Input = mockInput.mock.calls.find((call) =>
13101527
String((call[0] as { message?: string }).message).includes("Apple Private Key"),
13111528
)?.[0] as { validate: (value: string) => Promise<true | string> };

0 commit comments

Comments
 (0)