Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3e0b750
feat(init): connect native Apple setup to init and doctor
seanperez29 Oct 2, 2026
2525f87
fix(cli): propagate native cancellation and linking failures
seanperez29 Oct 2, 2026
bc4c378
fix(init): clarify application selection after native agent login
seanperez29 Oct 2, 2026
9eb7997
fix(init): guide agents through native application selection
seanperez29 Oct 2, 2026
0c35b7b
fix(init): honor explicit Apple project selections
seanperez29 Oct 2, 2026
85a6e28
fix(init): preserve noninteractive intent when linking
seanperez29 Oct 2, 2026
b73e565
refactor(init): keep web init behavior and add Apple setup alongside it
seanperez29 Oct 3, 2026
07b5881
feat(init): report application-required as JSON for iOS agents
seanperez29 Oct 3, 2026
bc1d0be
test(init): verify entitlement ownership across configurations with r…
seanperez29 Oct 3, 2026
71f82b1
fix(init): skip link's env pull advice during native iOS setup
seanperez29 Oct 4, 2026
b486ccc
feat(init): let people accept the signing team as the App ID Prefix
seanperez29 Oct 4, 2026
7a82ea4
fix(init): require an installed Xcode for native setup and note defer…
seanperez29 Oct 4, 2026
03daa78
fix(init): ask agents to confirm the signing team's App ID Prefix
seanperez29 Oct 4, 2026
5fead6e
Revert "fix(init): ask agents to confirm the signing team's App ID Pr…
seanperez29 Oct 4, 2026
be1e213
test(init): check that agents see the registered identity in JSON
seanperez29 Oct 4, 2026
82cdd40
feat(init): show which Xcode setup used when it isn't the default
seanperez29 Oct 5, 2026
742481f
test(init): give the ownership probe a Frontend API host and a Debug-…
seanperez29 Oct 5, 2026
d89a2f2
fix(init): never bootstrap a project for flags only existing Apple pr…
seanperez29 Oct 5, 2026
ab84a2c
test(init): probe an SDK-conditional entitlement on another platform'…
seanperez29 Oct 5, 2026
3cffad3
test(init): pass the minimum Xcode version in the package probe
seanperez29 Oct 5, 2026
fd40c34
fix(doctor): use init's Xcode check for iOS projects
seanperez29 Oct 8, 2026
dc5f460
test(init): stub the Xcode check in the coordinator doctor test
seanperez29 Oct 8, 2026
ac7664b
docs(init): note that backups go to .clerk/backups
seanperez29 Oct 8, 2026
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/native-apple-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Set up iOS and macOS apps with `clerk init`. Once init has linked a Clerk application, it links the Clerk Swift packages, configures capabilities, registers the native app, and optionally enables native Sign in with Apple, for both classic and JSON Xcode projects. Unchanged SwiftUI starters are initialized directly. New flags: `--dry-run` and `--json` (iOS only for now), `--xcode-project`, `--xcode-target`, `--xcode-configuration`, `--apple-sdk`, `--bundle-id`, `--app-id-prefix`, `--sign-in-with-apple`, and `--prebuilt-auth-ui`. `clerk doctor` adds read-only checks for Xcode projects.
25 changes: 19 additions & 6 deletions packages/cli-core/src/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ clerk doctor --fix # Offer to auto-fix issues

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--json` | Output results as machine-readable JSON |
| `--spotlight` | Only show warnings and failures (hide passing checks) |
| `--fix` | Offer to auto-fix issues with known remedies |
| Flag | Description |
| ------------------------------ | ---------------------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--json` | Output results as machine-readable JSON |
| `--spotlight` | Only show warnings and failures (hide passing checks) |
| `--fix` | Offer to auto-fix issues with known remedies |
| `--xcode-project <path>` | Xcode project or workspace to check; also selects the iOS checks |
| `--xcode-target <name-or-id>` | Xcode app target to check |
| `--xcode-configuration <name>` | Xcode build configuration to check |

## Checks

Expand Down Expand Up @@ -65,6 +68,16 @@ API application/instance-list concepts have no accountless equivalent), so they
continue to skip for an accountless project — the skip reason names the accountless
application instead of reading like a problem.

### iOS and macOS projects

When the directory is an Xcode project (or an `--xcode-*` flag is given), doctor
skips the env-file check, since native apps configure Clerk in Swift, and adds
read-only checks from the same engine as `clerk init`: configuration coverage,
SDK linkage and version, capabilities, Native API, registration, and the Apple
connection. It never edits the project, resolves packages, or writes to Clerk.
Without Xcode (another OS, or a Mac with only the Command Line Tools) these checks become one warning and the env-file check stays, matching `clerk init`'s fallback. If Clerk can't
be reached, the local checks still run and a warning says so.

## Auto-Fix (`--fix`)

When `--fix` is passed in human mode, the command prompts to fix each
Expand Down
68 changes: 68 additions & 0 deletions packages/cli-core/src/commands/doctor/index-ios.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test, expect, describe, beforeEach, mock } from "bun:test";
import { setMode } from "../../mode.ts";
import { useCaptureLog } from "../../test/lib/stubs.ts";
import { CHECK_NAME, type CheckKey, type CheckResult } from "./types.ts";

const pass = (key: CheckKey) => async (): Promise<CheckResult> => ({
name: CHECK_NAME[key],
status: "pass",
message: "ok",
});

// Replaced wholesale, so every export of checks.ts has to be here.
mock.module("./checks.ts", () => ({
checkCliVersion: pass("cliVersion"),
checkHostExecution: pass("hostExecution"),
checkLoggedIn: pass("loggedIn"),
checkTokenValid: pass("tokenValid"),
checkProjectLinked: pass("projectLinked"),
checkLinkedAppExists: pass("linkedAppExists"),
checkInstances: pass("instances"),
checkEnvVars: pass("envVars"),
checkConfigFile: pass("configFile"),
checkShellCompletion: pass("shellCompletion"),
}));
mock.module("./check-mcp.ts", () => ({ checkMcp: pass("mcp") }));
mock.module("./ios.ts", () => ({
runIOSDoctorChecks: async (): Promise<CheckResult[]> => [
{ name: "SDK project linkage", status: "pass", message: "ok" },
],
}));

let xcode = true;
mock.module("../init/ios/coordinator.ts", () => ({ canSetUpXcode: () => xcode }));

const { doctor } = await import("./index.ts");

describe("doctor for native Apple projects", () => {
const captured = useCaptureLog();
beforeEach(() => setMode("human"));

async function names(options: Parameters<typeof doctor>[0]): Promise<string[]> {
await doctor({ ...options, json: true });
return (JSON.parse(captured.out) as CheckResult[]).map((result) => result.name);
}

test("an Xcode selection adds the Apple checks and skips the env file check", async () => {
const result = await names({ xcodeTarget: "MyApp" });
expect(result).toContain("SDK project linkage");
expect(result).not.toContain(CHECK_NAME.envVars);
});

test("without Xcode, an iOS project keeps the env file check init falls back to", async () => {
xcode = false;
try {
const result = await names({ xcodeTarget: "MyApp" });
expect(result).toContain(CHECK_NAME.envVars);
expect(result).toContain("SDK project linkage");
} finally {
xcode = true;
}
});

test("other projects keep the usual checks", async () => {
const result = await names({});
expect(result).toContain(CHECK_NAME.envVars);
expect(result).not.toContain("SDK project linkage");
});
});
35 changes: 26 additions & 9 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import {
checkCliVersion,
} from "./checks.ts";
import { checkMcp } from "./check-mcp.ts";
import { runIOSDoctorChecks } from "./ios.ts";
import { canSetUpXcode } from "../init/ios/coordinator.ts";
import { detectFramework } from "../../lib/framework.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import {
CHECK_NAME,
Expand Down Expand Up @@ -53,10 +56,14 @@ const CHECKS = {
* Each check paired with the name to report it under if it throws. A check
* names its own results from the same `CHECK_NAME` entry, so the two agree.
*/
function getChecks(): { name: string; run: CheckFn }[] {
return (Object.keys(CHECKS) as CheckKey[])
.filter((key) => key !== "hostExecution" || isAgent())
.map((key) => ({ name: CHECK_NAME[key], run: CHECKS[key] }));
function getChecks(apple: boolean): { name: string; run: CheckFn }[] {
return (
(Object.keys(CHECKS) as CheckKey[])
.filter((key) => key !== "hostExecution" || isAgent())
// Native Apple apps configure Clerk in Swift, not an env file.
.filter((key) => key !== "envVars" || !apple)
.map((key) => ({ name: CHECK_NAME[key], run: CHECKS[key] }))
);
}

/**
Expand All @@ -66,9 +73,13 @@ function getChecks(): { name: string; run: CheckFn }[] {
* question and has no answer, and treating that as a pass would hide the one
* case where doctor itself is broken.
*/
async function runChecks(ctx: DoctorContext): Promise<CheckResult[]> {
return Promise.all(
getChecks().map(async ({ name, run }) => {
async function runChecks(ctx: DoctorContext, options: DoctorOptions): Promise<CheckResult[]> {
const apple =
Boolean(options.xcodeProject || options.xcodeTarget || options.xcodeConfiguration) ||
(await detectFramework(process.cwd()))?.dep === "ios";
const results = await Promise.all(
// Without Xcode, init pulls the key into an env file as before, so keep checking it.
getChecks(apple && canSetUpXcode()).map(async ({ name, run }) => {
try {
return await run(ctx);
} catch (error) {
Expand All @@ -81,6 +92,7 @@ async function runChecks(ctx: DoctorContext): Promise<CheckResult[]> {
}
}),
);
return apple ? [...results, ...(await runIOSDoctorChecks(ctx, options))] : results;
}

/**
Expand Down Expand Up @@ -113,7 +125,9 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
}

const ctx = createDoctorContext();
const allResults = await withSpinner("Running diagnostics...", async () => runChecks(ctx));
const allResults = await withSpinner("Running diagnostics...", async () =>
runChecks(ctx, options),
);

if (!options.json) {
printResults(allResults, options);
Expand Down Expand Up @@ -164,7 +178,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {

const verifyCtx = createDoctorContext();
const verifyResults = await withSpinner("Verifying fixes...", async () =>
runChecks(verifyCtx),
runChecks(verifyCtx, options),
);
printResults(verifyResults, { ...options, fix: false, spotlight: false });

Expand Down Expand Up @@ -192,6 +206,9 @@ export function registerDoctor(program: Program): void {
.option("--json", "Output results as JSON")
.option("--spotlight", "Only show warnings and failures")
.option("--fix", "Attempt to auto-fix issues")
.option("--xcode-project <path>", "Xcode project or workspace to check")
.option("--xcode-target <name-or-id>", "Xcode app target to check, by name or ID")
.option("--xcode-configuration <name>", "Xcode build configuration to check")
.setExamples([
{ command: "clerk doctor", description: "Run all health checks" },
{ command: "clerk doctor --verbose", description: "Show detailed output for each check" },
Expand Down
30 changes: 30 additions & 0 deletions packages/cli-core/src/commands/doctor/ios.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, expect, spyOn, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as coordinator from "../init/ios/coordinator.ts";
import { runIOSDoctorChecks } from "./ios.ts";
import type { DoctorContext } from "./types.ts";

const ctx = { getProfile: async () => undefined } as unknown as DoctorContext;
const xcode = spyOn(coordinator, "canSetUpXcode");
afterEach(() => xcode.mockReset());

test("without Xcode, the Xcode checks become one warning", async () => {
xcode.mockReturnValue(false);
expect(await runIOSDoctorChecks(ctx, {})).toEqual([
expect.objectContaining({ name: "Xcode project", status: "warn" }),
]);
});

test("an inspection failure reports its cause", async () => {
xcode.mockReturnValue(true);
const root = await mkdtemp(join(tmpdir(), "clerk-doctor-ios-"));
try {
const [result] = await runIOSDoctorChecks(ctx, { root });
expect(result).toMatchObject({ name: "Xcode project", status: "fail" });
expect(result?.message).toContain("none found");
} finally {
await rm(root, { recursive: true, force: true });
}
});
77 changes: 77 additions & 0 deletions packages/cli-core/src/commands/doctor/ios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { doctor } from "../init/ios/doctor.ts";
import { CLERK_SWIFT_MINIMUM_VERSION, canSetUpXcode } from "../init/ios/coordinator.ts";
import type { Dependencies } from "../init/ios/workflow.ts";
import { errorMessage } from "../../lib/errors.ts";
import { interruptSignal } from "../../lib/signals.ts";
import type { CheckResult, DoctorContext, DoctorOptions } from "./types.ts";

const NAME = "Xcode project";

/** Read-only checks for a native Apple app: Xcode project, packages, capabilities, registration. */
export async function runIOSDoctorChecks(
ctx: DoctorContext,
options: Pick<DoctorOptions, "xcodeProject" | "xcodeTarget" | "xcodeConfiguration"> & {
root?: string;
},
dependencies: Dependencies = {},
): Promise<CheckResult[]> {
// Same rule as init: without a usable Xcode, init took the manual (env file) path.
if (!canSetUpXcode())
return [
{
name: NAME,
status: "warn",
message: "Checking an Xcode project needs Xcode on macOS.",
remedy: "Run clerk doctor on a Mac with Xcode installed.",
},
];
const signal = interruptSignal();
const profile = await ctx.getProfile();
const setup = {
root: options.root ?? process.cwd(),
project: options.xcodeProject,
target: options.xcodeTarget,
configuration: options.xcodeConfiguration,
products: "core" as const,
minimumVersion: CLERK_SWIFT_MINIMUM_VERSION,
inspectOnly: true,
signal,
remote: profile ? { applicationId: profile.profile.appId } : undefined,
};
let report: Awaited<ReturnType<typeof doctor>>;
let remoteError: unknown;
try {
try {
report = await doctor(setup, dependencies);
} catch (error) {
signal.throwIfAborted();
if (!setup.remote) throw error;
// Keep the local checks when Clerk can't be reached or the account lacks access.
remoteError = error;
report = await doctor({ ...setup, remote: undefined }, dependencies);
}
} catch (error) {
signal.throwIfAborted();
return [
{
name: NAME,
status: "fail",
message: `The Xcode project could not be inspected: ${errorMessage(error)}`,
remedy:
"Run from the folder with your .xcodeproj, or pass --xcode-project and --xcode-target.",
},
];
}
const results: CheckResult[] = report.checks.map((check) => ({
...check,
remedy: check.status === "pass" ? undefined : "Run clerk init to complete the remaining setup.",
}));
if (remoteError)
results.push({
name: "Clerk native settings",
status: "warn",
message: `Clerk settings could not be checked: ${errorMessage(remoteError)}`,
remedy: "Check your Clerk login and application access, then rerun clerk doctor.",
});
return results;
}
3 changes: 3 additions & 0 deletions packages/cli-core/src/commands/doctor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,7 @@ export interface DoctorOptions {
json?: boolean;
spotlight?: boolean;
fix?: boolean;
xcodeProject?: string;
xcodeTarget?: string;
xcodeConfiguration?: string;
}
Loading
Loading