Skip to content

Commit a37f77e

Browse files
committed
fix(init): harden iOS native reconciliation
1 parent e9e8e91 commit a37f77e

4 files changed

Lines changed: 384 additions & 35 deletions

File tree

packages/cli-core/src/commands/init/index-ios.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { IOSLocalSetupResult } from "./ios/apply.ts";
3030
import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts";
3131
import type { IOSNativeApplePlan } from "./ios/native-apple.ts";
3232
import type { IOSNativeRemotePlan } from "./ios/native-remote.ts";
33+
import type { IOSNativeReadinessTarget } from "./ios/native-readiness.ts";
3334
import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts";
3435

3536
const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`;
@@ -138,6 +139,14 @@ function iosSetupResult(overrides: Partial<IOSLocalSetupResult> = {}): IOSLocalS
138139
};
139140
}
140141

142+
function selectedNativeTarget(
143+
overrides: Partial<Extract<IOSNativeReadinessTarget, { status: "selected" }>> = {},
144+
): Extract<IOSNativeReadinessTarget, { status: "selected" }> {
145+
const target = FAKE_IOS_NATIVE_READINESS.target;
146+
if (target.status !== "selected") throw new Error("Expected a selected iOS test target");
147+
return { ...target, ...overrides };
148+
}
149+
141150
describe("init iOS", () => {
142151
const { setup, track } = useInitHarness();
143152

@@ -241,6 +250,155 @@ describe("init iOS", () => {
241250
}
242251
});
243252

253+
test("requires a confirmed App ID Prefix before an agent creates or links a new application", async () => {
254+
setup({ isAgent: true, email: "test@test.com" });
255+
const iosCtx = nativeIOSContext();
256+
spyOn(context, "gatherContext").mockResolvedValue(iosCtx);
257+
spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(
258+
iosSetupResult({
259+
requiresLinkedApp: true,
260+
nativeReadiness: {
261+
...FAKE_IOS_NATIVE_READINESS,
262+
target: selectedNativeTarget({
263+
appIdPrefix: { status: "missing", source: "literal-entitlements" },
264+
}),
265+
},
266+
unverifiedAppIdPrefixSuggestion: {
267+
source: "xcode-development-team",
268+
value: "ABCDE12345",
269+
},
270+
}),
271+
);
272+
273+
await expect(init({ yes: true })).rejects.toThrow(
274+
"Ask the user whether to use ABCDE12345 or enter a different App ID Prefix",
275+
);
276+
277+
expect(linkMod.link).not.toHaveBeenCalled();
278+
expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled();
279+
expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled();
280+
expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled();
281+
});
282+
283+
test("names an agent-created Clerk application after the selected Xcode target", async () => {
284+
setup({ isAgent: true, email: "test@test.com" });
285+
const iosCtx = nativeIOSContext();
286+
spyOn(context, "gatherContext").mockResolvedValue(iosCtx);
287+
spyOn(config, "resolveProfile")
288+
.mockResolvedValueOnce(undefined)
289+
.mockResolvedValueOnce(undefined)
290+
.mockResolvedValue({ profile: { appId: "app_test" } } as never);
291+
spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(
292+
iosSetupResult({
293+
targetName: "AnotherPromptTest",
294+
requiresLinkedApp: true,
295+
nativeReadiness: {
296+
...FAKE_IOS_NATIVE_READINESS,
297+
target: selectedNativeTarget({
298+
projectPath: "ContainerProject.xcodeproj",
299+
targetName: "AnotherPromptTest",
300+
appIdPrefix: { status: "missing", source: "literal-entitlements" },
301+
}),
302+
},
303+
}),
304+
);
305+
306+
await init({ yes: true, appIdPrefix: "CONFIRMED123" });
307+
308+
expect(linkMod.link).toHaveBeenCalledWith({
309+
skipIfLinked: true,
310+
app: undefined,
311+
cwd: iosCtx.cwd,
312+
createIfMissing: "AnotherPromptTest",
313+
skipAutolink: true,
314+
});
315+
expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith(
316+
expect.objectContaining({
317+
appIdPrefix: "CONFIRMED123",
318+
applicationLinkChange: "created-and-linked",
319+
}),
320+
);
321+
});
322+
323+
test("lets an explicit existing app supply its registered prefix without auto-creation", async () => {
324+
setup({ isAgent: true, email: "test@test.com" });
325+
const iosCtx = nativeIOSContext();
326+
spyOn(context, "gatherContext").mockResolvedValue(iosCtx);
327+
spyOn(config, "resolveProfile")
328+
.mockResolvedValueOnce(undefined)
329+
.mockResolvedValueOnce(undefined)
330+
.mockResolvedValue({ profile: { appId: "app_existing" } } as never);
331+
spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(
332+
iosSetupResult({
333+
requiresLinkedApp: true,
334+
nativeReadiness: {
335+
...FAKE_IOS_NATIVE_READINESS,
336+
target: selectedNativeTarget({
337+
appIdPrefix: { status: "missing", source: "literal-entitlements" },
338+
}),
339+
},
340+
}),
341+
);
342+
spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({
343+
appId: "app_existing",
344+
instanceId: "ins_existing",
345+
instanceLabel: "development",
346+
publishableKey: VALID_DEVELOPMENT_KEY,
347+
});
348+
spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(
349+
iosRemotePlan({
350+
applicationId: "app_existing",
351+
instanceId: "ins_existing",
352+
appIdPrefix: "REGISTERED123",
353+
nativeApi: "satisfied",
354+
registration: "satisfied",
355+
status: "satisfied",
356+
actions: [],
357+
}),
358+
);
359+
360+
await init({ yes: true, app: "app_existing" });
361+
362+
expect(linkMod.link).toHaveBeenCalledWith({
363+
skipIfLinked: true,
364+
app: "app_existing",
365+
cwd: iosCtx.cwd,
366+
createIfMissing: undefined,
367+
skipAutolink: true,
368+
});
369+
expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith(
370+
expect.objectContaining({
371+
appIdPrefix: undefined,
372+
applicationLinkChange: "link-updated",
373+
}),
374+
);
375+
});
376+
377+
test("does not auto-create a replacement when an existing iOS link disappears", async () => {
378+
setup({ isAgent: true, email: "test@test.com" });
379+
const iosCtx = nativeIOSContext();
380+
spyOn(context, "gatherContext").mockResolvedValue(iosCtx);
381+
spyOn(config, "resolveProfile")
382+
.mockResolvedValueOnce({ profile: { appId: "app_existing" } } as never)
383+
.mockResolvedValue(undefined);
384+
spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(
385+
iosSetupResult({ requiresLinkedApp: true }),
386+
);
387+
388+
await expect(init({ yes: true })).rejects.toThrow(
389+
"The Clerk application link could not be verified",
390+
);
391+
392+
expect(linkMod.link).toHaveBeenCalledWith({
393+
skipIfLinked: true,
394+
app: undefined,
395+
cwd: iosCtx.cwd,
396+
createIfMissing: undefined,
397+
skipAutolink: true,
398+
});
399+
expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled();
400+
});
401+
244402
test("rejects --allow-dirty with --dry-run before project work", async () => {
245403
setup();
246404

packages/cli-core/src/commands/init/index.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
} from "./ios/apply.ts";
8484
import {
8585
applyIOSNativeRemoteSetup,
86+
assertIOSAppIdPrefixBeforeApplicationCreation,
8687
prepareIOSNativeRemoteSetup,
8788
validateAppIdPrefix,
8889
} from "./ios/native-remote.ts";
@@ -435,19 +436,35 @@ export async function init(options: InitOptions = {}) {
435436
assertKeylessOnlyFlags(options, strategy);
436437

437438
let authenticatedAppId: string | undefined;
439+
let iosApplicationLinkChange: "created-and-linked" | "link-updated" | undefined;
438440
if (strategy === "authenticate") {
439441
setTelemetryStage("link");
442+
if (agent && iosLocalSetup?.requiresLinkedApp && !iosProfile && !options.app) {
443+
assertIOSAppIdPrefixBeforeApplicationCreation({
444+
target: iosLocalSetup.nativeReadiness.target,
445+
appIdPrefix: options.appIdPrefix,
446+
...(iosLocalSetup.unverifiedAppIdPrefixSuggestion
447+
? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion }
448+
: {}),
449+
});
450+
}
440451
bar();
441-
const createIfMissing = agent
442-
? await deriveProjectName(ctx.cwd, bootstrap?.projectName)
452+
const mayCreateApplication =
453+
agent && (ctx.framework.dep !== "ios" || (!iosProfile && !options.app));
454+
const createIfMissing = mayCreateApplication
455+
? await deriveProjectName(ctx.cwd, bootstrap?.projectName ?? iosLocalSetup?.targetName)
443456
: undefined;
444-
authenticatedAppId = await authenticateAndLink(
457+
const authenticated = await authenticateAndLink(
445458
ctx.cwd,
446459
options.app,
447460
createIfMissing,
448461
iosLocalSetup?.requiresLinkedApp === true,
449462
preauthenticatedIOSLabel,
450463
);
464+
authenticatedAppId = authenticated.applicationId;
465+
if (ctx.framework.dep === "ios") {
466+
iosApplicationLinkChange = authenticated.applicationLinkChange;
467+
}
451468
}
452469

453470
let authenticatedKeysHandled = false;
@@ -529,6 +546,7 @@ export async function init(options: InitOptions = {}) {
529546
...(iosLocalSetup.unverifiedAppIdPrefixSuggestion
530547
? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion }
531548
: {}),
549+
...(iosApplicationLinkChange ? { applicationLinkChange: iosApplicationLinkChange } : {}),
532550
agent,
533551
yes: options.yes === true,
534552
});
@@ -1095,15 +1113,18 @@ async function authenticateAndLink(
10951113
createIfMissing: string | undefined,
10961114
requireLinkedAppId: boolean,
10971115
preauthenticatedLabel?: string,
1098-
): Promise<string | undefined> {
1116+
): Promise<{
1117+
applicationId?: string;
1118+
applicationLinkChange?: "created-and-linked" | "link-updated";
1119+
}> {
10991120
const label = preauthenticatedLabel ?? (await resolveAuthLabel());
11001121
const profile = await resolveProfile(cwd);
11011122

11021123
const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app);
11031124

11041125
if (label && alreadyOnRequestedApp) {
11051126
log.info(dim(`${label} · Linked to ${profile.profile.appId}`));
1106-
return profile.profile.appId;
1127+
return { applicationId: profile.profile.appId };
11071128
}
11081129

11091130
if (label) {
@@ -1131,7 +1152,17 @@ async function authenticateAndLink(
11311152
code: ERROR_CODE.NOT_LINKED,
11321153
});
11331154
}
1134-
return linked?.profile.appId;
1155+
const applicationId = linked?.profile.appId;
1156+
const applicationLinkChange =
1157+
applicationId && !profile && !app && createIfMissing
1158+
? ("created-and-linked" as const)
1159+
: applicationId && profile?.profile.appId !== applicationId
1160+
? ("link-updated" as const)
1161+
: undefined;
1162+
return {
1163+
applicationId,
1164+
...(applicationLinkChange ? { applicationLinkChange } : {}),
1165+
};
11351166
}
11361167

11371168
// --- Keyless app setup ---

packages/cli-core/src/commands/init/ios/native-remote.test.ts

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,25 +392,100 @@ describe("Clerk Native Application remote setup", () => {
392392
});
393393
});
394394

395-
test("requires an explicit prefix in agent mode instead of prompting", async () => {
395+
test("offers an unverified Xcode suggestion in agent mode instead of prompting", async () => {
396396
const { api } = scriptedAPI({
397397
nativeReads: [nativeSettings(false)],
398398
registrationReads: [[]],
399399
});
400400

401-
await expect(
402-
prepareIOSNativeRemoteSetup(
403-
prepareOptions({
404-
target: selectedTarget({ appIdPrefix: null }),
405-
unverifiedAppIdPrefixSuggestion: {
406-
source: "xcode-development-team",
407-
value: "ABCDE12345",
408-
},
409-
agent: true,
401+
const error = await prepareIOSNativeRemoteSetup(
402+
prepareOptions({
403+
target: selectedTarget({ appIdPrefix: null }),
404+
unverifiedAppIdPrefixSuggestion: {
405+
source: "xcode-development-team",
406+
value: "ABCDE12345",
407+
},
408+
agent: true,
409+
}),
410+
{ api, prompts: prompts() },
411+
).catch((cause: unknown) => cause);
412+
413+
expect(error).toBeInstanceOf(Error);
414+
const message = (error as Error).message;
415+
expect(message).toContain("ABCDE12345");
416+
expect(message).toContain("Xcode DEVELOPMENT_TEAM");
417+
expect(message).toContain("unverified suggestion");
418+
expect(message).toContain("Ask the user whether to use ABCDE12345 or enter a different");
419+
expect(message).toContain('--app-id-prefix "<confirmed_prefix>"');
420+
});
421+
422+
test("offers partial literal entitlement evidence in agent mode", async () => {
423+
const { api } = scriptedAPI({
424+
nativeReads: [nativeSettings(false)],
425+
registrationReads: [[]],
426+
});
427+
428+
const error = await prepareIOSNativeRemoteSetup(
429+
prepareOptions({
430+
target: selectedTarget({
431+
appIdPrefix: null,
432+
appIdPrefixCandidates: [LOCAL_PREFIX],
410433
}),
411-
{ api, prompts: prompts() },
412-
),
413-
).rejects.toThrow("requires --app-id-prefix");
434+
agent: true,
435+
}),
436+
{ api, prompts: prompts() },
437+
).catch((cause: unknown) => cause);
438+
439+
expect(error).toBeInstanceOf(Error);
440+
const message = (error as Error).message;
441+
expect(message).toContain(LOCAL_PREFIX);
442+
expect(message).toContain("literal App ID Prefix evidence");
443+
expect(message).toContain("unverified suggestion");
444+
expect(message).toContain(`Ask the user whether to use ${LOCAL_PREFIX} or enter a different`);
445+
});
446+
447+
test("directs the agent to Apple Developer when no prefix suggestion exists", async () => {
448+
const { api } = scriptedAPI({
449+
nativeReads: [nativeSettings(false)],
450+
registrationReads: [[]],
451+
});
452+
453+
const error = await prepareIOSNativeRemoteSetup(
454+
prepareOptions({
455+
target: selectedTarget({ appIdPrefix: null }),
456+
agent: true,
457+
}),
458+
{ api, prompts: prompts() },
459+
).catch((cause: unknown) => cause);
460+
461+
expect(error).toBeInstanceOf(Error);
462+
const message = (error as Error).message;
463+
expect(message).toContain("requires --app-id-prefix");
464+
expect(message).toContain("copy the value labeled App ID Prefix in Apple Developer");
465+
expect(message).toContain('--app-id-prefix "<confirmed_prefix>"');
466+
});
467+
468+
test("reports an application link that changed before a missing-prefix block", async () => {
469+
const { api } = scriptedAPI({
470+
nativeReads: [nativeSettings(false)],
471+
registrationReads: [[]],
472+
});
473+
474+
const error = await prepareIOSNativeRemoteSetup(
475+
prepareOptions({
476+
target: selectedTarget({ appIdPrefix: null }),
477+
applicationLinkChange: "link-updated",
478+
agent: true,
479+
}),
480+
{ api, prompts: prompts() },
481+
).catch((cause: unknown) => cause);
482+
483+
expect(error).toBeInstanceOf(Error);
484+
expect((error as Error).message).toContain("The project's Clerk application link was updated");
485+
expect((error as Error).message).toContain(
486+
"no Xcode or Clerk Native Application settings changes were written",
487+
);
488+
expect((error as Error).message).not.toContain("No local or remote setup changes were written");
414489
});
415490

416491
test("blocks an explicit prefix that conflicts with a partial local candidate", () => {

0 commit comments

Comments
 (0)