Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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/ios-established-app-operation-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Improve native Apple setup with clearer setup messages, safe independent operations in established apps, capability edits isolated from unrelated Associated Domains issues, and Doctor registration checks when Swift source discovery is incomplete.
55 changes: 55 additions & 0 deletions docs/native-established-apps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Established native Apple apps

Support depends on the operation's evidence, not the app's age. Existing custom
runtime configuration remains developer-owned. Selecting a Clerk app with
`--app` authorizes setup against that app; it does not prove which app a custom
runtime publishable key belongs to.

`clerk init` selects a sole application target automatically. With several targets,
an interactive terminal offers a picker showing each target's name, platform, and
project. `--target` bypasses the picker. Agent mode, `--yes`, JSON output, and
redirected input/output require explicit selection when targets are ambiguous;
`--dry-run` can report the available choices without changing anything. Choosing a
target retains every operation's safety checks. Copied projects with colliding
target IDs still require running from the intended project's directory.

Existing `AuthView` is evidence for SDK product and version requirements. When its
runtime wiring is unresolved, it does not activate automatic AuthView setup or
provider-capability changes. SDK linkage and native registration may still
proceed. Explicit `--prebuilt-auth-ui` requests retain the runtime checks.

Entitlement edits require safe file selection and proven ownership. File paths
and each capability’s evaluated settings must agree with Xcode’s packaging
settings as well as its compiler settings. An unresolved
or malformed Associated Domains value blocks editing that capability, but does
not by itself block a Sign in with Apple or macOS networking edit in the same
valid XML dictionary. Shared files, unresolved paths, and invalid plist structure
still block edits. Any permitted edit preserves unrelated capability values.

| Operation | Required evidence | Effect of uncertain custom startup wiring |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Link SDK products | Exhaustive target discovery, consistent platform views, complete source membership for product selection, and a safe package/project edit | May proceed when those prerequisites hold; preserve Swift |
| Register the native app during init | Explicit Clerk app selection for custom configuration, one Bundle ID across configurations/platforms, verified App ID Prefix, and additive remote reconciliation | May proceed when the local preflight and identity checks pass; does not verify runtime configuration |
| Doctor registration check | Linked Clerk app/development instance and independently verified target/platform identity; registration audit must resolve or report prefix ambiguity | May run despite incomplete Swift membership; reports only Native API and registration state |
| Rewrite runtime configuration or insert prebuilt UI | Proven runtime/source ownership and the relevant source plan's existing checks | Remains blocked; no parser expansion or inferred startup execution |
| Configure associated domains | Proven domain/key inputs and the capability planner's ownership checks | An unproven custom startup call does not supply a domain; preserve domain values and report manual follow-up |
| Diagnose key matching, AuthView, or Apple authentication | The relevant runtime, source, entitlement, and linked-app evidence | Doctor's registration-only fallback does not run these checks or imply they passed |

Registration uses the effective `CFBundleIdentifier`: generated plist settings or
an explicit XML Info.plist with supported build-setting expansion. The identity
must agree across configurations, platforms, and compiler/packaging contexts.
Missing, unreadable, preprocessed, or otherwise unresolved plist inputs block
registration; selecting `--app` does not bypass those checks.

`init` still rejects incomplete source discovery before edits: its SDK choice and
combined local plan depend on that evidence. Doctor is read-only and can retain a
source-discovery failure while reporting an independently supported registration
result. Unresolved containers, target/platform selection, conflicting identities,
and divergent platform Swift setup remain blockers. The registration-only proof
is not an approved mutation plan.

The real-Xcode-derived [established app fixture](../test/fixtures/ios-established/README.md)
exercises partial integration and a rerun with existing package linkage and remote
registration. Its tests check both safe progress and preserved blockers. This is
a focused acceptance case, not a claim to understand arbitrary Swift startup code
or every mature Xcode project layout.
19 changes: 19 additions & 0 deletions packages/cli-core/src/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,25 @@ describe("login", () => {
return server;
}

test("embedded login preserves the outer setup flow and omits standalone next steps", async () => {
mockIsHuman.mockReturnValue(true);
mockGetAuth.mockResolvedValue(null);
mockOpenBrowser.mockResolvedValue({ ok: true, launcher: "test" });
mockOAuthSuccess();
const spinner = await import("../../lib/spinner.ts");
const { isInsideGutter } = await import("../../lib/log.ts");
spinner.intro("Setting up Clerk");
try {
await login({ embedded: true, showNextSteps: false });
expect(isInsideGutter()).toBe(true);
expect(captured.err).not.toContain("Signing in");
expect(captured.err).not.toContain("Next steps");
expect(mockStoreToken).toHaveBeenCalled();
} finally {
await spinner.outro();
}
});

test("returns early when already authenticated with valid token", async () => {
mockGetValidToken.mockResolvedValue("existing-token");
mockGetAuth.mockResolvedValue({ userId: "user_123" });
Expand Down
14 changes: 8 additions & 6 deletions packages/cli-core/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { currentTelemetryStage, setTelemetryStage } from "../../lib/telemetry.ts
import { ensureFirstApplication } from "../../lib/first-application.ts";

interface LoginOptions {
/** Keep the caller’s setup flow open instead of rendering a nested command. */
embedded?: boolean;
showNextSteps?: boolean;
yes?: boolean;
}
Expand Down Expand Up @@ -147,7 +149,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {

async function runLogin(options: LoginOptions = {}): Promise<UserInfo> {
const { showNextSteps = true, yes } = options;
intro("Signing in");
if (!options.embedded) intro("Signing in");
setTelemetryStage("session_check");
const existingSession = await withSpinner("Checking session...", async () =>
getExistingSession(),
Expand All @@ -157,9 +159,9 @@ async function runLogin(options: LoginOptions = {}): Promise<UserInfo> {
setTelemetryStage("done");
log.success(`Logged in as ${existingSession.email}`);
const claimResult = await handleAutoclaim(process.cwd());
if (showNextSteps) {
if (!options.embedded && showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
} else if (!options.embedded) {
await outro("Done");
}
return existingSession;
Expand All @@ -171,7 +173,7 @@ async function runLogin(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
await outro();
if (!options.embedded) await outro();
throwUserAbort();
}
}
Expand Down Expand Up @@ -207,9 +209,9 @@ async function runLogin(options: LoginOptions = {}): Promise<UserInfo> {

const claimResult = await handleAutoclaim(process.cwd());

if (showNextSteps) {
if (!options.embedded && showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
} else if (!options.embedded) {
await outro("Done");
}

Expand Down
5 changes: 5 additions & 0 deletions packages/cli-core/src/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ in human or JSON output. AuthView, Native Application, and Apple remote checks
are GET-only. Their remedies point back to `clerk init`; `doctor --fix` never
enables an auth strategy or changes Native Application state.

Incomplete Swift source discovery remains a diagnostic failure, but does not
suppress a Native Application check when target identity is independently proven
across all supported platforms. That registration-only check does not establish
runtime-key matching, AuthView compatibility, or Apple authentication readiness.

`clerk doctor` inspects configuration and remote Clerk state without invoking
Xcode package resolution, builds, or Simulator execution. Build and runtime
verification remain with Xcode and the project's existing test workflow.
Expand Down
112 changes: 110 additions & 2 deletions packages/cli-core/src/commands/doctor/ios.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import {
convertIOSFixtureToMultiplatform,
convertIOSFixtureToPlatformFilteredAppRoots,
Expand Down Expand Up @@ -1935,3 +1935,111 @@ struct MyApp: App {
expect(JSON.stringify(audit.results)).not.toContain("apple-secret-value");
});
});

describe("established apps with incomplete source discovery", () => {
async function establishedFixture(conflictingBundle = false) {
const root = await mkdtemp(join(tmpdir(), "clerk-established-doctor-"));
roots.push(root);
await cp(resolve(import.meta.dir, "../../../../../test/fixtures/ios-established"), root, {
recursive: true,
});
const path = join(root, "ClerkCorpusIOS.xcodeproj", "project.pbxproj");
let project = await readFile(path, "utf8");
// A missing build-file record leaves additional Swift membership unknown,
// while the selected target's build settings and entitlements remain readable.
project = project.replace(
/(isa = PBXSourcesBuildPhase;[\s\S]*?files = \()/,
"$1 FEFEFEFEFEFEFEFEFEFEFEFE,",
);
if (conflictingBundle) {
project = project.replace(
"PRODUCT_BUNDLE_IDENTIFIER = com.clerk.ClerkCorpusIOS;",
"PRODUCT_BUNDLE_IDENTIFIER = com.clerk.OtherApp;",
);
}
await writeFile(path, project);
return root;
}

test.each([true, false])(
"checks registration independently when registered=%s",
async (registered) => {
const root = await establishedFixture();
let registrationReads = 0;
const unexpected = async (): Promise<never> => {
throw new Error("Source-dependent inspection must remain blocked");
};
const { inspection, results } = await runIOSDoctorChecks(
context(),
{ root },
dependencies({
fetchApplication: unexpected,
fetchUserSettings: unexpected,
planIOSSDKInstall: unexpected,
planMacOSNetworkCapability: unexpected,
auditIOSNativeAppleHealth: unexpected,
planIOSAppleEntitlement: unexpected,
listIOSApplications: async () => {
registrationReads += 1;
return registered
? [
{
object: "ios_application",
id: "iosapp_test",
app_id_prefix: "LEGACY1234",
bundle_id: "com.clerk.ClerkCorpusIOS",
created_at: 1,
updated_at: 1,
},
]
: [];
},
}),
);
expect(inspection.appTargets[0]!.swift.evidenceComplete).toBe(false);
expect(registrationReads).toBe(1);
expect(results).toContainEqual(
expect.objectContaining({
name: "iOS: Native Application",
status: registered ? "pass" : "fail",
message: registered
? "Native API and iOS registration: configured"
: "Native API or iOS registration: setup required",
}),
);
expect(
results.some(
(result) =>
result.status === "fail" &&
`${result.message} ${result.detail}`.includes("could not be inspected completely"),
),
).toBe(true);
expect(results.some((result) => result.name.includes("Linked development key"))).toBe(false);
expect(results.some((result) => result.name.includes("Linked Clerk application"))).toBe(
false,
);
},
);

test("still refuses remote reads when incomplete sources accompany conflicting target identity", async () => {
const root = await establishedFixture(true);
let remoteReads = 0;
const { results } = await runIOSDoctorChecks(
context(),
{ root },
dependencies({
getNativeSettings: async () => {
remoteReads += 1;
return { object: "native_settings", api_enabled: true };
},
listIOSApplications: async () => {
remoteReads += 1;
return [];
},
}),
);
expect(remoteReads).toBe(0);
expect(results.some((result) => result.name === "iOS: Native Application")).toBe(false);
expect(results.some((result) => result.status === "fail")).toBe(true);
});
});
45 changes: 34 additions & 11 deletions packages/cli-core/src/commands/doctor/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
inspectIOSPlatformViews,
iosPlatformViewsHaveAppleEntitlementIntent,
iosPlatformViewsHaveNativeAppleIntent,
type IOSPlatformNativeIdentity,
type IOSPlatformViewsSnapshot,
} from "../init/ios/platform-views.ts";
import { hasSupportedIOSCustomConfigure } from "../init/ios/products.ts";
Expand Down Expand Up @@ -471,8 +472,12 @@ async function remoteResults(
inspection: IOSProjectInspectionResult,
dependencies: IOSDoctorDependencies,
platformViews?: IOSPlatformViewsSnapshot,
registrationIdentity?: IOSPlatformNativeIdentity,
): Promise<CheckResult[]> {
const readiness = buildIOSNativeReadinessAudit(inspection, { platformViews });
const registrationOnly = registrationIdentity != null;
const readiness = buildIOSNativeReadinessAudit(inspection, {
platformViews: platformViews ?? registrationIdentity,
});
const target = selectedTarget(inspection);
const platform = target?.platform ?? (inspection.platform === "macos" ? "macos" : "ios");
const nativeApplicationName = `${platformLabel(platform)}: Native Application`;
Expand All @@ -499,7 +504,7 @@ async function remoteResults(

const profile = await ctx.getProfile();
if (!profile) {
if (target) {
if (target && !registrationOnly) {
const authView = await authViewEnvironmentResult(target, dependencies, {
root: inspection.root,
configureStatus: configureStep?.status,
Expand Down Expand Up @@ -537,9 +542,9 @@ async function remoteResults(
const instanceId = profile.profile.instances.development;
try {
const [application, remotePlan] = await Promise.all([
dependencies.fetchApplication(applicationId, {
includeSecretKeys: false,
}),
registrationOnly
? undefined
: dependencies.fetchApplication(applicationId, { includeSecretKeys: false }),
auditIOSNativeRemoteSetup(
{ applicationId, instanceId, target: readiness.target },
{
Expand All @@ -548,12 +553,13 @@ async function remoteResults(
},
),
]);
const customApplication = customSource
? linkedCustomApplicationResult(application, instanceId, platform)
: undefined;
const customApplication =
customSource && application
? linkedCustomApplicationResult(application, instanceId, platform)
: undefined;
const linkedResult =
customApplication?.result ??
(configureStep?.status === "satisfied"
(application && configureStep?.status === "satisfied"
? linkedDevelopmentKeyResult(inspection, application, instanceId)
: undefined);
const localPublishableKey = inspection.localPublishableKey;
Expand All @@ -562,7 +568,7 @@ async function remoteResults(
(!customSource && linkedResult?.status === "pass" && localPublishableKey.state === "valid"
? localPublishableKey.frontendApiHost
: undefined);
if (target) {
if (target && !registrationOnly) {
const authView = await authViewEnvironmentResult(target, dependencies, {
root: inspection.root,
configureStatus: configureStep?.status,
Expand Down Expand Up @@ -603,10 +609,16 @@ async function remoteResults(
? `Native API or ${platformLabel(platform)} registration: setup required`
: `Native API or ${platformLabel(platform)} registration: blocked`,
...(detail ? { detail } : {}),
remedy: REMOTE_REMEDY,
remedy: registrationOnly
? "Resolve the reported source-discovery issue, then run `clerk init --target <target>`; native registration can also be completed in the Clerk Dashboard."
: REMOTE_REMEDY,
});
}

// Source discovery is incomplete: registration does not establish runtime
// key matching, AuthView compatibility, or Sign in with Apple readiness.
if (registrationOnly) return results;

const bundleIdentifier = readiness.target.bundleIdentifier;
const hasAppleEntitlement = platformViews
? iosPlatformViewsHaveAppleEntitlementIntent(platformViews)
Expand Down Expand Up @@ -824,6 +836,17 @@ export async function runIOSDoctorChecks(
platformCompatibilityBlockers,
);
if (platformViewsAudit?.status === "blocked") {
if (platformViewsAudit.nativeIdentity) {
results.push(
...(await remoteResults(
ctx,
inspection,
dependencies,
undefined,
platformViewsAudit.nativeIdentity,
)),
);
}
return { inspection, results };
}
if (target && !target.platformEvidenceComplete) {
Expand Down
Loading
Loading