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/calm-apples-inspect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

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.
28 changes: 27 additions & 1 deletion packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { maybeNotifyUpdate } from "./lib/update-check.ts";
import { CURRENT_VERSION } from "./lib/version.ts";
import { registerExtras } from "@clerk/cli-extras";
import {
discardCommandTelemetry,
finalizeAndSendTelemetry,
startCommandTelemetry,
telemetryResultForError,
Expand All @@ -61,6 +62,18 @@ export type Program = Command<[], { inputJson?: string; mode?: string; verbose?:

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

/**
* `init --dry-run` promises an invocation-wide read-only boundary. Keep the
* check here, outside the init action, so global hooks cannot send telemetry,
* fetch an update, or persist their caches around an otherwise read-only run.
*/
function isReadOnlyInitDryRun(actionCommand: {
name(): string;
getOptionValue(key: string): unknown;
}): boolean {
return actionCommand.name() === "init" && actionCommand.getOptionValue("dryRun") === true;
}

const registrants: CommandRegistrant[] = [
registerInit,
registerAuth,
Expand Down Expand Up @@ -109,8 +122,15 @@ export function createProgram(): Program {
.option("--verbose", "Show detailed output (enables debug messages)") as Program;

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

// Environment selection only affects remote Clerk operations. Avoid even
// reading or rendering persisted CLI environment state for this local-only
// inspection path.
if (readOnlyInitDryRun) return;

// Initialize the active environment from persisted config
const envName = await getEnvironment();
if (envName && isValidEnv(envName)) {
Expand All @@ -150,6 +175,7 @@ export function createProgram(): Program {
// Show update notification after each command, except for commands that
// already perform their own version check (doctor, update).
program.hook("postAction", async (_thisCommand, actionCommand) => {
if (isReadOnlyInitDryRun(actionCommand)) return;
const cmdName = actionCommand.name();
if (cmdName === "doctor" || cmdName === "update") return;
await maybeNotifyUpdate(CURRENT_VERSION);
Expand Down
217 changes: 217 additions & 0 deletions packages/cli-core/src/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const mockPatchInstanceConfig = mock();
const mockFetchInstanceConfig = mock();
const mockFetchInstanceConfigSchema = mock();
const mockFetchApplication = mock();
const mockListIOSApplications = mock();
const mockGetNativeSettings = mock();
const mockListApplicationDomains = mock();
const mockCreateProductionInstance = mock();
const mockGetApplicationDomainStatus = mock();
Expand All @@ -49,6 +51,8 @@ mock.module("../../lib/plapi.ts", () => ({
fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args),
fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args),
fetchApplication: (...args: unknown[]) => mockFetchApplication(...args),
listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args),
getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args),
listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args),
createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args),
getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args),
Expand Down Expand Up @@ -228,6 +232,8 @@ describe("deploy", () => {
mockGetApplicationDomainStatus.mockResolvedValue(
domainStatus({ status: "complete", dns: true, ssl: true, mail: true }),
);
mockListIOSApplications.mockResolvedValue([]);
mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true });
stubCreateProductionInstance();
mockTriggerApplicationDomainDNSCheck.mockResolvedValue(
domainStatus({ status: "complete", dns: true, ssl: true, mail: true }),
Expand Down Expand Up @@ -261,6 +267,8 @@ describe("deploy", () => {
mockFetchInstanceConfig.mockReset();
mockFetchInstanceConfigSchema.mockReset();
mockFetchApplication.mockReset();
mockListIOSApplications.mockReset();
mockGetNativeSettings.mockReset();
mockListApplicationDomains.mockReset();
mockCreateProductionInstance.mockReset();
mockGetApplicationDomainStatus.mockReset();
Expand Down Expand Up @@ -1253,6 +1261,213 @@ describe("deploy", () => {
expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback");
});

test("skips Apple web credential prompts for an exact native-only production registration", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
});
mockLiveProduction({
instanceId: "ins_prod_native_apple",
developmentConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
productionConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
});
mockListIOSApplications.mockResolvedValueOnce([
{
object: "ios_application",
id: "ios_native",
app_id_prefix: "ABCDE12345",
bundle_id: "com.example.native",
created_at: 1,
updated_at: 1,
},
]);
mockIsAgent.mockReturnValue(false);

await runDeploy({});

expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple");
expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple");
expect(mockSelect).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
expect(mockPassword).not.toHaveBeenCalled();
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
const err = stripAnsi(captured.err);
expect(err).toContain("No deploy actions remain.");
expect(err).toContain("OAuth Apple");
expect(err).not.toContain("Configure Apple OAuth for production");
});

test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
});
mockLiveProduction({
instanceId: "ins_prod_native_apple",
developmentConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
productionConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
});
mockListIOSApplications.mockResolvedValueOnce([
{
object: "ios_application",
id: "ios_other",
app_id_prefix: "OTHER12345",
bundle_id: "com.example.other",
created_at: 1,
updated_at: 1,
},
]);
mockIsAgent.mockReturnValue(false);

await expect(runDeploy({})).rejects.toThrow(
"the production instance does not have an exact iOS Native Application registration for that Bundle ID",
);

expect(mockSelect).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
expect(mockPassword).not.toHaveBeenCalled();
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
expect(stripAnsi(captured.err)).toContain("Failed");
});

test("preserves Ctrl-C while verifying a native-only Apple registration", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
});
mockLiveProduction({
instanceId: "ins_prod_native_apple",
developmentConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
productionConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
});
mockListIOSApplications
.mockRejectedValueOnce(new Error("native status endpoint unavailable"))
.mockRejectedValueOnce(promptExitError());
mockIsAgent.mockReturnValue(false);

await expect(runDeploy({})).rejects.toMatchObject({ exitCode: EXIT_CODE.SIGINT });

expect(mockSelect).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
expect(mockPassword).not.toHaveBeenCalled();
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
expect(stripAnsi(captured.err)).toContain("Paused");
});

test("refuses native-only Apple when production Native API is disabled", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
});
mockLiveProduction({
instanceId: "ins_prod_native_apple",
developmentConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
productionConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
});
mockListIOSApplications.mockResolvedValue([
{
object: "ios_application",
id: "ios_native",
app_id_prefix: "ABCDE12345",
bundle_id: "com.example.native",
created_at: 1,
updated_at: 1,
},
]);
mockGetNativeSettings.mockResolvedValue({
object: "native_settings",
api_enabled: false,
});
mockIsAgent.mockReturnValue(false);

await expect(runDeploy({})).rejects.toThrow(
"Enable Native API at https://dashboard.clerk.com/~/native-applications",
);

expect(mockSelect).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
expect(mockPassword).not.toHaveBeenCalled();
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
});

test("refuses native-only Apple that is not explicitly authenticatable", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_native_apple" },
});
mockLiveProduction({
instanceId: "ins_prod_native_apple",
developmentConfig: {
connection_oauth_apple: {
enabled: true,
authenticatable: true,
bundle_id: "com.example.native",
},
},
productionConfig: {
connection_oauth_apple: {
enabled: true,
bundle_id: "com.example.native",
},
},
});
mockIsAgent.mockReturnValue(false);

await expect(runDeploy({})).rejects.toThrow(
"Apple is not explicitly enabled for authentication on the production instance",
);

expect(mockListIOSApplications).not.toHaveBeenCalled();
expect(mockGetNativeSettings).not.toHaveBeenCalled();
expect(mockSelect).not.toHaveBeenCalled();
expect(mockInput).not.toHaveBeenCalled();
expect(mockPassword).not.toHaveBeenCalled();
expect(mockPatchInstanceConfig).not.toHaveBeenCalled();
});

test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => {
await linkedProject({
instances: { development: "ins_dev_123", production: "ins_prod_apple" },
Expand Down Expand Up @@ -1306,6 +1521,8 @@ describe("deploy", () => {
"-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n",
},
});
expect(mockListIOSApplications).not.toHaveBeenCalled();
expect(mockGetNativeSettings).not.toHaveBeenCalled();
const p8Input = mockInput.mock.calls.find((call) =>
String((call[0] as { message?: string }).message).includes("Apple Private Key"),
)?.[0] as { validate: (value: string) => Promise<true | string> };
Expand Down
Loading
Loading