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

`clerk init --starter` now creates an Android app: "Android (Jetpack Compose)" in the picker, or `--framework android` with `--name` and `--android-package`. It writes a project shaped like Android Studio's Empty Activity template (AGP 9.2.1, Kotlin 2.3.21, a Compose BOM, `minSdk` 24, an initial Git commit, and the embedded Gradle 9.4.1 wrapper), then runs the Android setup on it with the UI artifact by default, and with `--prebuilt-auth-ui` (or a yes to the prompt) replaces `MainActivity` with Clerk's prebuilt sign-in screen. `--json` reports `input-required`, `application-required`, and `setup-interrupted` with the new app's continuation, as for the Apple starters.
11 changes: 8 additions & 3 deletions packages/cli-core/src/commands/init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ clerk init --no-skills
| `--app-id-prefix <prefix>` | 10-character Apple App ID Prefix for a new native registration |
| `--team-id <id>` | Apple Developer Team ID for a new iOS or macOS app with `--starter` (skips the team prompt) |
| `--sign-in-with-apple` | Enable native Sign in with Apple (entitlement and Clerk Apple connection) |
| `--prebuilt-auth-ui` | Add ClerkKitUI's prebuilt sign-in screen to an unchanged SwiftUI starter |
| `--prebuilt-auth-ui` | Add Clerk's prebuilt sign-in screen to an unchanged SwiftUI (ClerkKitUI) or Compose (clerk-android-ui) starter |
| `--android-module <path>` | Android application module to set up, relative to the project root. Selects Android setup even when the directory has no root manifest; required when the project has several application modules |
| `--android-package <id>` | The installed `applicationId` to register, including any variant suffix, when Gradle's isn't one plain literal (flavors, a computed value, a suffix outside the debug build type) |
| `--android-fingerprint <sha256...>` | SHA-256 signing certificate fingerprints to register besides the debug keystore's: a release or Play App Signing certificate, or this machine's when `keytool` is unavailable (from `./gradlew signingReport`) |
Expand All @@ -62,6 +62,7 @@ When running in agent mode (`--mode agent`, `CLERK_MODE=agent`, a recognized age
- For **existing projects**: framework and package manager are auto-detected, no flags required
- For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided
- Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided
- An Android starter (`--framework android`) takes `--name` and `--android-package` (defaulting to `com.example.<lowercased name>`); with `--json`, `--name` is required and a missing or invalid answer is an `input-required` status, as for the Apple starter. Without `--app` or a linked profile, init creates the app and prints the next step, `cd <app> && clerk init --app <app_id> --json`, with this run's Android options (see **New apps** under Android)
- An Android project needs a chosen application too: without `--app` or a linked profile, init prints `clerk init --app <app_id> --json` with this run's `--android-*` options (with `--json`, as an `application-required` status). With one, `--json` reports `selection-required` (several application modules; pass `--android-module`) or `input-required` (no signing certificate fingerprint is known and nothing is registered yet; pass `--android-fingerprint`) before anything is written, and otherwise the setup result with the Kotlin handoff (see [Android (Kotlin)](#android-kotlin))
- An iOS or macOS starter (`--framework ios` or `macos`) takes `--bundle-id` (defaulting to Xcode's Organization Identifier plus the name) and `--team-id` (defaulting to the only paid team, or else the only team, in Xcode's accounts). Without `--app` or a linked profile, init creates the app and prints the next step, `cd <app> && clerk init --app <app_id> --json`, with this run's setup options (see **New apps** under iOS and macOS)
- For accountless-capable frameworks with no `--app` and no linked profile:
Expand Down Expand Up @@ -261,7 +262,10 @@ Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./f

### Android (Kotlin)

Once init has linked a Clerk application (the usual authenticated flow), it sets up the Gradle application module directly instead of scaffolding files or pulling keys into `.env`. It works on any OS: inspection reads Gradle scripts, the version catalog, and the manifest as text, never running Gradle, and the only tool it runs is `keytool`, to read the debug certificate. The engine lives in [`android/`](./android/) and mirrors the iOS one module for module (discovery, project inspection, plan, SDK, identity, remote, workflow, coordinator, handoff, doctor); what the two share (resolving the instance, idempotent registration, file snapshots, backups, and rollback, progress) lives in [`native/`](./native/).
- **New apps.** `clerk init --starter` offers Android (Jetpack Compose) everywhere, alongside the web frameworks and, on a Mac with Xcode, the Apple starters; `--framework android` selects it. It asks for the app name (an identifier such as `MyApp`) and the applicationId (default `com.example.<lowercased name>`), then writes a project shaped like Android Studio's Empty Activity template before signing in or choosing a Clerk application: `settings.gradle.kts`, the root build script, `gradle.properties`, a version catalog pinning AGP 9.2.1, Kotlin 2.3.21 (through the Compose compiler plugin alias, so AGP's built-in Kotlin isn't the 2.2.10 the SDK can't be consumed by), and a Compose BOM; `app/` with `minSdk` 24, the manifest, `MainActivity`, the theme, and a vector launcher icon; `.gitignore` and an initial Git commit (unless the folder is already inside a repository); and the Gradle wrapper. The wrapper's `gradlew`, `gradlew.bat`, and `gradle-wrapper.jar` (Gradle 9.4.1) are embedded in the CLI and copied in, with the distribution's checksum pinned in `gradle-wrapper.properties`, so the new project builds with `./gradlew :app:assembleDebug` without Gradle on PATH; the Android SDK and a JDK 17+ are still needed to build. Setup then runs as for an existing project, with no confirmation prompt and the UI artifact by default. The new app has no screens of its own, so its sign-in screen defaults to on, with `--yes` as at the prompt, unless `--android-sdk core` is chosen; an agent still adds it only with `--prebuilt-auth-ui`. `--android-module` and the accountless flags are rejected before anything is written. When an agent hasn't chosen a Clerk application yet, it's told to run `cd <app> && clerk init --app <app_id> --json`, carrying this run's Android options.
- **New apps with `--json`.** `clerk init --starter --framework android --json` reports like the Apple starters: `--name` is required, an answer only the user can give stops before anything is written with `input-required` (`field` is `name` or `androidPackage`, with the flag and question), every status after the app is written carries `starter: { projectName, platform: "android", projectDir, continuation }`, and a setup error with no status of its own is `setup-interrupted`, so an agent continues inside the new app instead of rerunning `--starter`.
- **Prebuilt sign-in screen.** With the UI artifact, an unchanged starter's `MainActivity` is replaced by one that shows a progress indicator until `Clerk.isInitialized`, `AuthView(isDismissible = false)` until `Clerk.isAuthFlowCompleteFlow`, then `UserButton()` and the signed-in email, keeping the app's theme. Anything other than the unchanged template is left alone, and the handoff's `optional-sign-in-ui` task stays behind user intent.
Once init has linked a Clerk application (the usual authenticated flow), it sets up the Gradle application module directly instead of scaffolding files or pulling keys into `.env`. It works on any OS: inspection reads Gradle scripts, the version catalog, and the manifest as text, never running Gradle, and the only tool it runs is `keytool`, to read the debug certificate. The engine lives in [`android/`](./android/) and mirrors the iOS one module for module (discovery, project inspection, plan, SDK, identity, remote, workflow, coordinator, handoff, doctor); what the two share (resolving the instance, idempotent registration, file snapshots, backups, and rollback, progress) lives in [`native/`](./native/).

- **Discovery.** Finds application modules (a `src/main/AndroidManifest.xml` beside a build script that applies `com.android.application`, literally or through a catalog alias) up to three folders deep, and asks when there's more than one. `--android-module` selects explicitly, and selects Android setup even when the directory has no root manifest. Both Kotlin and Groovy DSL scripts are read, and `settings.gradle(.kts)` for `pluginManagement` versions and the module's Gradle project path (`include` and `projectDir` remaps), which the handoff's build task uses. Anything the inspection can't read as one plain value is refused rather than guessed: a computed or repeated `namespace` or `applicationId`, product flavors, an `applicationIdSuffix` outside the debug build type, a manifest with several `<application>` elements, more than one top-level `dependencies` block, a `clerk.xml` or `clerk_publishable_key` resource it didn't write. A refused layout doesn't end init: it says why, then takes the path Android had before native setup, linking the app, pulling the publishable key into the env file, and printing the manual quickstart steps (with `--json`, a `manual-setup-required` status carrying `reason`, `envFile`, and `next`). A value that merely can't be read, such as `minSdk` set by a convention plugin, is noted and setup goes on without it.
- **Identity.** The registered package name is the installed `applicationId`: `defaultConfig.applicationId` plus the debug build type's `applicationIdSuffix` when there is one, since the development instance is what debug builds use; `--android-package` names it when Gradle's value isn't one plain literal. The signing fingerprints are the SHA-256 of this machine's debug certificate (`~/.android/debug.keystore`, or under `ANDROID_USER_HOME`; read with `keytool` from PATH, `JAVA_HOME`, or Android Studio's bundled JDK) plus any `--android-fingerprint`, normalized to uppercase colon-separated form and deduplicated. Clerk serves `/.well-known/assetlinks.json` from the registered fingerprints, which passkeys need, so a new registration needs at least one: when the keystore doesn't exist yet or no `keytool` is available, init says so and what to pass, and an agent gets an `input-required` status naming `--android-fingerprint`. The JSON result's `identity` shows what was registered and where each fingerprint came from.
Expand All @@ -278,12 +282,13 @@ Once init has linked a Clerk application (the usual authenticated flow), it sets
- **Safety.** The same rules as iOS: every write checks the file hasn't changed since the preview (content, mode, device, and inode). Files git can't restore are backed up to `.clerk/backups/` at the project root, outside `src/`, where Gradle would compile or bundle them, and a failed write restores earlier ones and attempts no remote change. Registration is idempotent, so rerunning init finishes anything left incomplete and a second run changes nothing.
- **Exit codes.** 0 when setup succeeds, even if Kotlin integration or manual steps remain (the JSON `status` says which); nonzero when a step fails, with `android_setup_blocked`, `android_setup_stale`, or `android_remote_verify_failed` as the error code.

`--dry-run` inspects the module without signing in, reading Clerk, or running `keytool`, so the key resource and registration are planned only once an application is chosen. `--prebuilt-auth-ui` stays iOS-only for now: there's no unchanged Android starter to add a sign-in screen to, so it's a usage error on an Android project.
`--dry-run` inspects the module without signing in, reading Clerk, or running `keytool`, so the key resource and registration are planned only once an application is chosen. `--prebuilt-auth-ui` applies to an unchanged Android starter as it does to a SwiftUI one; with `--android-sdk core` it's a usage error, since the screen needs `clerk-android-ui`.

```sh
clerk init --dry-run --json
clerk init --android-sdk ui
clerk init --android-module mobile --android-package com.example.app.debug --android-fingerprint AA:BB:... --yes
clerk init --starter --framework android --name MyApp --android-package com.example.myapp --json --app app_123
```

### iOS and macOS (Swift)
Expand Down
121 changes: 121 additions & 0 deletions packages/cli-core/src/commands/init/android/coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { readFile, rm } from "node:fs/promises";
import { writeAndroidStarter } from "./starter-project.ts";
import { join } from "node:path";
import * as prompts from "../../../lib/prompts.ts";
import * as lists from "../../../lib/listage.ts";
Expand Down Expand Up @@ -207,3 +208,123 @@ describe("clerk init for Android", () => {
});
});
});

describe("clerk init for a new Android starter", () => {
async function starterFixture() {
const cwd = await createAndroidFixture();
roots.push(cwd);
await rm(join(cwd, "app"), { recursive: true, force: true });
await rm(join(cwd, "gradle"), { recursive: true, force: true });
await rm(join(cwd, "build.gradle.kts"), { force: true });
const dir = join(cwd, "MyApp");
await writeAndroidStarter(dir, { name: "MyApp", applicationId: "com.example.myapp" });
const { api, state } = fakeNativeAPI();
return {
root: dir,
state,
dependencies: { api, debugKeystore: keystore() },
starterProject: {
projectName: "MyApp",
platform: "android" as const,
projectDir: dir,
continuation: "cd MyApp && clerk init --app app_test --json",
},
};
}

test("--yes adds the prebuilt sign-in screen and skips the confirmation", async () => {
const f = await starterFixture();
const confirm = spyOn(prompts, "confirm").mockRejectedValue(new Error("no prompt expected"));
const outro = spyOn(spinner, "outro").mockResolvedValue(undefined);
spies.push(confirm, outro);
await runAndroidInit(
{ root: f.root, agent: false, yes: true, starterProject: f.starterProject },
"app_test",
f.dependencies,
);
expect(confirm).not.toHaveBeenCalled();
const activity = await readFile(
join(f.root, "app/src/main/java/com/example/myapp/MainActivity.kt"),
"utf8",
);
expect(activity).toContain("AuthView(isDismissible = false)");
expect(await readFile(join(f.root, "app/build.gradle.kts"), "utf8")).toContain(
"implementation(libs.clerk.android.ui)",
);
expect(captured.err).toContain("sign-in screen added");
expect(captured.err).toContain("Open MyApp in Android Studio");
expect(outro).toHaveBeenCalledWith("CLI setup complete; app verification remains");
});

test("interactively, the starter asks only about the sign-in screen", async () => {
const f = await starterFixture();
const confirm = spyOn(prompts, "confirm").mockResolvedValue(false);
const select = spyOn(lists, "select").mockRejectedValue(
new Error("no artifact question expected"),
);
spies.push(confirm, select, spyOn(spinner, "outro").mockResolvedValue(undefined));
await runAndroidInit(
{ root: f.root, agent: false, starterProject: f.starterProject },
"app_test",
f.dependencies,
);
expect(confirm.mock.calls.map((call) => call[0].message)).toEqual([
"Add Clerk’s prebuilt sign-in screen?",
]);
expect(confirm.mock.calls[0]![0].default).toBe(true);
const activity = await readFile(
join(f.root, "app/src/main/java/com/example/myapp/MainActivity.kt"),
"utf8",
);
expect(activity).toContain("Greeting(");
});

test("an agent adds the screen only with --prebuilt-auth-ui, and every status carries the starter", async () => {
const f = await starterFixture();
await runAndroidInit(
{ root: f.root, agent: true, json: true, starterProject: f.starterProject },
"app_test",
f.dependencies,
);
const result = JSON.parse(captured.out);
expect(result.starter).toMatchObject({ projectName: "MyApp", platform: "android" });
expect(
result.handoff.tasks.find((task: { id: string }) => task.id === "optional-sign-in-ui"),
).toMatchObject({ status: "requires-user-intent" });
expect(
await readFile(join(f.root, "app/src/main/java/com/example/myapp/MainActivity.kt"), "utf8"),
).toContain("Greeting(");
captured.clear();
await runAndroidInit(
{
root: f.root,
agent: true,
json: true,
prebuiltAuthUi: true,
starterProject: f.starterProject,
},
"app_test",
f.dependencies,
);
expect(
JSON.parse(captured.out).handoff.completed.map((item: { id: string }) => item.id),
).toContain("optional-sign-in-ui");
});

test("a setup error after writing reports setup-interrupted with the continuation", async () => {
const f = await starterFixture();
f.state.wrongApp = true;
await expect(
runAndroidInit(
{ root: f.root, agent: true, json: true, starterProject: f.starterProject },
"app_test",
f.dependencies,
),
).rejects.toThrow();
expect(JSON.parse(captured.out)).toMatchObject({
status: "setup-interrupted",
starter: { projectName: "MyApp" },
next: [expect.stringContaining("cd MyApp && clerk init --app app_test --json")],
});
});
});
Loading
Loading