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/ios-native-init-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add end-to-end native Apple setup to `clerk init`, including dry-run, target selection, optional AuthView, and Sign in with Apple.
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
108 changes: 82 additions & 26 deletions packages/cli-core/src/commands/init/README.md

Large diffs are not rendered by default.

190 changes: 184 additions & 6 deletions packages/cli-core/src/commands/init/frameworks/ios.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
import { test, expect } from "bun:test";
import { afterAll, afterEach, test, expect, spyOn } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ios } from "./ios.ts";
import type { ProjectContext } from "./types.ts";
import { createIOSFixture } from "../ios/test-helpers.ts";
import * as associatedDomain from "../ios/associated-domain.ts";

const temporaryRoots: string[] = [];
const emptyRoot = await mkdtemp(join(tmpdir(), "clerk-ios-framework-empty-"));

afterAll(() => rm(emptyRoot, { recursive: true, force: true }));

afterEach(async () => {
await Promise.all(
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});

async function makeIOSFixture(complete: boolean, clerkSDK = true): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-"));
temporaryRoots.push(root);
await createIOSFixture(root, { complete, clerkSDK });
return root;
}

function makeCtx(): ProjectContext {
return {
cwd: "/tmp/ios-app",
cwd: emptyRoot,
framework: {
dep: "ios",
name: "iOS (Swift)",
Expand Down Expand Up @@ -36,20 +59,175 @@ test("writes no files and prints the quickstart steps", async () => {
expect(
plan.postInstructions.some((i) => i.includes("ClerkKit") && i.includes("ClerkKitUI")),
).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("prebuilt AuthView path"))).toBe(true);
expect(
plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")),
).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true);
// The official quickstart requires injecting Clerk into the SwiftUI
// environment — views read it back via @Environment(Clerk.self).
expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe(
true,
);
expect(plan.postInstructions.some((i) => i.includes("--prebuilt-auth-ui"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false);
// With no inspectable target, keep the guidance explicitly conditional.
expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("docs/ios/getting-started/quickstart"))).toBe(
true,
);
});

test("references the project's env file for the publishable key", async () => {
test("uses direct @main configuration as the fresh-project default", async () => {
const plan = await ios.scaffold({ ...makeCtx(), envFile: ".env.local" });

expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes("single shipping `@main` App"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("value redacted"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(false);
expect(
plan.postInstructions.some(
(i) => i.includes("Run scheme") && i.includes("manual runtime configuration"),
),
).toBe(false);
});

test("defers the Associated Domain host to ready direct configuration", async () => {
const root = await makeIOSFixture(false);
const unrelatedKey = `pk_test_${Buffer.from("unrelated-framework.clerk.example$").toString("base64")}`;
await Bun.write(join(root, ".env"), `CLERK_PUBLISHABLE_KEY=${unrelatedKey}\n`);
const planner = spyOn(associatedDomain, "planIOSAssociatedDomain");

try {
const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });

expect(planner).toHaveBeenCalledWith(
expect.objectContaining({
root,
deferToPublishableKey: true,
}),
);
expect(
plan.postInstructions.some((instruction) => instruction.includes("Associated Domains")),
).toBe(true);
expect(plan.postInstructions.join("\n")).not.toContain("unrelated-framework.clerk.example");
} finally {
planner.mockRestore();
}
});

test("omits manual Native Applications guidance after authenticated remote verification", async () => {
const plan = await ios.scaffold({ ...makeCtx(), iosNativeRemoteReady: true });

expect(
plan.postInstructions.some((instruction) =>
instruction.includes("dashboard.clerk.com/~/native-applications"),
),
).toBe(false);
});

test("explains that the prebuilt AuthView exposes Apple automatically after native setup", async () => {
const root = await makeIOSFixture(true);
const plan = await ios.scaffold({
...makeCtx(),
cwd: root,
iosTarget: "MyApp",
iosNativeRemoteReady: true,
iosNativeAppleReady: true,
});

expect(
plan.postInstructions.some(
(instruction) =>
instruction.includes("Native Sign in with Apple is ready") &&
instruction.includes("AuthView displays the Apple button automatically"),
),
).toBe(true);
});

test("preserves a custom LocalSecrets loader without interpreting its value", async () => {
const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-local-secrets-"));
temporaryRoots.push(root);
await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true });
await Bun.write(
join(root, "MyApp", "LocalSecrets.plist"),
'<?xml version="1.0"?><plist version="1.0"><dict><key>CLERK_PUBLISHABLE_KEY</key><string>not-a-key</string></dict></plist>',
);

const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });

expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes("custom key value"))).toBe(false);
expect(
plan.postInstructions.some((i) => i.includes("single shipping `@main` App initializer")),
).toBe(false);
expect(plan.postInstructions.some((i) => i.includes(".env"))).toBe(false);
});

test("includes SwiftUI environment injection for the default prebuilt path", async () => {
const root = await makeIOSFixture(false);
const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });

expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe(
true,
);
});

test("keeps existing custom-flow installation and environment guidance core-only", async () => {
const root = await makeIOSFixture(false, false);
await Bun.write(
join(root, "MyApp", "MyAppApp.swift"),
`import ClerkKit
import SwiftUI

@main
struct MyApp: App {
var body: some Scene { WindowGroup { Text("Custom auth") } }
}
`,
);
const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });
const installInstruction = plan.postInstructions.find((instruction) =>
instruction.includes("github.com/clerk/clerk-ios"),
);

expect(installInstruction).toContain("link ClerkKit for this existing custom-flow path");
expect(installInstruction).not.toContain("ClerkKitUI");
expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes("custom ClerkKit"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("ClerkKitUI's prebuilt AuthView"))).toBe(
false,
);
});

test("omits SwiftUI environment injection when it is already present", async () => {
const root = await makeIOSFixture(true);
const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });

expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false);
});

test("does not derive setup state from a LocalSecrets value", async () => {
const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-satisfied-"));
temporaryRoots.push(root);
await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true });
const encodedHost = Buffer.from("clerk.example.test$").toString("base64");
await Bun.write(
join(root, "MyApp", "LocalSecrets.plist"),
`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CLERK_PUBLISHABLE_KEY</key><string>pk_test_${encodedHost}</string></dict></plist>`,
);

const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" });

expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(true);
expect(plan.postInstructions.some((i) => i.includes("Configure Clerk"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe(
false,
);
expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false);
expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false);
expect(
plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")),
).toBe(true);
});
Loading
Loading