From d975d193dc1823c6077ccbf181ef4a93ace68f7f Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 17 Sep 2026 10:57:53 -0700 Subject: [PATCH] feat(init): automate native Android setup Configure Android native registration and Gradle/Kotlin initialization through clerk init, with dry-run previews and safe reruns. --- .changeset/android-native-setup.md | 5 + packages/cli-core/src/commands/init/README.md | 64 +- .../src/commands/init/android/project.test.ts | 303 ++++++++++ .../src/commands/init/android/project.ts | 550 ++++++++++++++++++ .../src/commands/init/android/setup.test.ts | 219 +++++++ .../src/commands/init/android/setup.ts | 132 +++++ .../src/commands/init/index-android.test.ts | 87 +++ packages/cli-core/src/commands/init/index.ts | 90 ++- packages/cli-core/src/lib/plapi.ts | 109 +++- test/e2e/fixtures/android/README.md | 13 +- .../e2e/fixtures/android/app/build.gradle.kts | 13 + .../android/app/src/main/AndroidManifest.xml | 6 +- test/e2e/fixtures/android/build.gradle.kts | 3 + test/e2e/native-init.test.ts | 69 ++- 14 files changed, 1626 insertions(+), 37 deletions(-) create mode 100644 .changeset/android-native-setup.md create mode 100644 packages/cli-core/src/commands/init/android/project.test.ts create mode 100644 packages/cli-core/src/commands/init/android/project.ts create mode 100644 packages/cli-core/src/commands/init/android/setup.test.ts create mode 100644 packages/cli-core/src/commands/init/android/setup.ts create mode 100644 packages/cli-core/src/commands/init/index-android.test.ts create mode 100644 test/e2e/fixtures/android/app/build.gradle.kts create mode 100644 test/e2e/fixtures/android/build.gradle.kts diff --git a/.changeset/android-native-setup.md b/.changeset/android-native-setup.md new file mode 100644 index 000000000..d89b6ec34 --- /dev/null +++ b/.changeset/android-native-setup.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Automate Android setup in `clerk init`: enable Native API, register the application and signing fingerprints, install the SDK, and initialize Clerk in Kotlin. Add a read-only dry run and explicit module/application ID options for variant-aware setup. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index de2eb7cf3..026b5f608 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -107,7 +107,7 @@ Native mobile platforms may not have a `package.json`, so they are detected from | `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | | `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | -A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over. +A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. iOS prints manual Swift Package Manager instructions and pulls the publishable key to an env file. Android uses the dedicated [Android setup](#android-setup) flow; use `--android-module` for a nonstandard module directory. The **Accountless** column indicates whether the framework's Clerk SDK supports accountless mode (auto-generated temporary dev keys). Accountless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--accountless` forces accountless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on an accountless-capable framework creates a real app named after the project and links it. @@ -115,7 +115,7 @@ Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `ya ## Scaffolding -Scaffolding is supported for every detected framework. iOS and Android write no files (their SDKs are not npm packages and their build files are not safe to modify automatically) — instead they print the exact quickstart steps as post-instructions. +Scaffolding is supported for every detected framework. iOS prints quickstart instructions. Android inspects supported Gradle/Kotlin layouts, previews changes, and configures native registration and initialization. All scaffolding is idempotent — files are skipped if they already contain Clerk setup. @@ -232,9 +232,11 @@ A post-instruction reminds the user that `types/globals.d.ts` must be covered by Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./frameworks/node-server.ts). The entry file is resolved from `package.json#main` (ignored when it points at build output like `dist/`) and common candidates (`[src/]index|server|app|main` with `.ts/.mts/.js/.mjs/.cjs`, ordered by basename so an unrelated `src/app.ts` can't outrank a root `index.js`). The resolved path is the one named in the `--env-file` post-instruction. Both ESM (`import`) and CommonJS (`require`, including the inline `require("fastify")(...)` form) are supported; injection lands after the full creation statement, so multi-line options objects and chained calls (e.g. `.withTypeProvider()`) are safe. When no entry or creation call is found, a post-instruction with the quickstart link is printed instead. -### iOS (Swift) / Android (Kotlin) +### iOS (Swift) -No files are written. The scaffold plan prints the quickstart steps: SDK install (Swift Package Manager for `ClerkKit`/`ClerkKitUI`, Gradle for `com.clerk:clerk-android-*`), enabling the Native API and registering the app on the Dashboard's Native Applications page, and configuring the publishable key in source (`Clerk.configure(...)` / `Clerk.initialize(...)`) by copying it from the pulled env file. +No source files are written. The scaffold plan prints Swift Package Manager, +Native Applications registration, and publishable-key setup instructions. +Android uses the dedicated [Android setup](#android-setup) flow. ## Agent skills install @@ -315,3 +317,57 @@ The breadcrumb is also what protects an unclaimed accountless app from being orp - **`--fresh`**: the explicit escape hatch. Skips the check entirely and mints a new application (and overwrites the env keys and breadcrumb), even in agent mode or with `-y`. Like `--template`, it's a usage error when combined with `--login` or whenever the run doesn't resolve to accountless. If no breadcrumb exists (first run, or the previous app was already claimed and the breadcrumb removed), init proceeds exactly as before — there's nothing to protect. + +## Android setup + +For a Kotlin Android application, `clerk init --app ` configures the +selected Clerk **development** instance and the local application module: + +- Enables Native API and registers `applicationId` with the `android_app` Digital + Asset Links namespace. The Gradle `namespace` is used for Kotlin/R references; + it is not used as the installed application ID. +- Adds `com.clerk:clerk-android-api:1.1.7`, raising `minSdk` below 24 to 24 locally. Literal values and simple version-catalog references are supported. +- Aligns literal Kotlin plugin versions or the shared `kotlin` catalog version with Kotlin 2.4.20 when needed. For AGP 9 built-in Kotlin, adds a root KGP classpath dependency. Builds requiring KSP/kapt migration or custom toolchain configuration stop for manual setup. +- Registers a generated `Application` subclass, or adds initialization after + `super.onCreate()` in a supported existing Kotlin `Application`. +- Writes the development publishable key to `src/main/res/values/clerk.xml` and + reads it from Kotlin. No secret key or unused `.env` file is written. + +Use Java 17 or newer to sync/build. This initializes the API SDK; add custom +sign-in UI or Clerk's prebuilt UI separately. Play Integrity credentials remain +Dashboard-managed. + +```sh +clerk init --dry-run +clerk init --app app_123 --android-fingerprint AA:BB:... --yes +clerk init --framework android --android-module mobile --android-package com.example.app.debug --app app_123 +``` + +`--dry-run` inspects local files and previews the plan without authenticating, +calling setup APIs, or writing project files. It works before login. Normal runs +preview remote and local changes before confirmation; `--yes` and agent mode +apply the plan without a prompt. + +`--android-module` is a relative directory, not a Gradle task path. Ambiguous +multi-module projects require it. Projects with flavors, application ID suffixes, +or external build logic require `--android-package` with the final installed +variant ID. Automatic source edits require a literal Gradle namespace and a +supported Kotlin Application; unsupported layouts stop with manual instructions. + +Supply one or more SHA-256 signing fingerprints via `--android-fingerprint`. +Find these with `./gradlew signingReport`; use the certificate for the installed +build (including the Play app-signing certificate for Play-distributed builds). +Without fingerprints, native registration and initialization proceed, but +passkey association still requires fingerprints in Dashboard > Native Applications. + +Reruns reuse an equivalent registration and leave matching files unchanged. +Existing registrations missing a requested fingerprint must be corrected in the +Dashboard, because the Platform API provides list/create, not update/delete. +Completed requests use stable idempotency keys. Native setup is additive: if a +later step fails, remote changes may already exist and a rerun reconciles them. +Local writes check for edits since preview and roll back on write failures where +possible. Existing unmanaged Clerk initialization is preserved and reported for +manual setup. + +This flow requires the Native Settings and Android Applications Platform API +endpoints, plus `applications:read` and `applications:manage` permissions. diff --git a/packages/cli-core/src/commands/init/android/project.test.ts b/packages/cli-core/src/commands/init/android/project.test.ts new file mode 100644 index 000000000..e03a018c2 --- /dev/null +++ b/packages/cli-core/src/commands/init/android/project.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyAndroidProject, + inspectAndroidProject, + normalizeFingerprints, + withPublishableKey, +} from "./project.ts"; + +let root: string; +const build = `plugins { id("com.android.application"); id("org.jetbrains.kotlin.android") version "2.4.20" } +android { + namespace = "com.example.code" + compileSdk = 35 + defaultConfig { + applicationId = "com.example.installed" + minSdk = 23 + } +} +dependencies { + implementation("example:existing:1.0") +} +`; +const manifest = ``; +const key = "pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk"; +const sourcePath = "app/src/main/kotlin/com/example/code/ClerkApplication.kt"; +const write = async (path: string, value: string) => { + await Bun.write(join(root, path), value); +}; +const read = async (path: string) => Bun.file(join(root, path)).text(); + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "clerk-android-project-")); + await write("app/build.gradle.kts", build); + await write("app/src/main/AndroidManifest.xml", manifest); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe("Android project setup", () => { + test("plans and applies Kotlin, Gradle and manifest setup using applicationId instead of namespace", async () => { + const plan = await inspectAndroidProject(root); + expect(plan.packageName).toBe("com.example.installed"); + expect(await read("app/build.gradle.kts")).toBe(build); + expect(plan.plan.actions).toHaveLength(4); + await applyAndroidProject(withPublishableKey(plan, key)); + expect(await read("app/build.gradle.kts")).toContain( + 'implementation("com.clerk:clerk-android-api:1.1.7")', + ); + expect(await read("app/build.gradle.kts")).toContain("minSdk = 24"); + expect(await read("app/build.gradle.kts")).toContain("example:existing:1.0"); + expect(await read(sourcePath)).toContain( + "getString(com.example.code.R.string.clerk_publishable_key)", + ); + expect(await read("app/src/main/AndroidManifest.xml")).toContain( + 'android:name="com.example.code.ClerkApplication"', + ); + expect(await read("app/src/main/AndroidManifest.xml")).toContain("android.permission.INTERNET"); + expect(await read("app/src/main/res/values/clerk.xml")).toContain(key); + expect(await Bun.file(join(root, ".env")).exists()).toBe(false); + }); + + test("rerunning with the same key makes no file changes", async () => { + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + const rerun = withPublishableKey(await inspectAndroidProject(root), key); + expect(rerun.plan.actions).toEqual([]); + }); + + test("supports Groovy build scripts", async () => { + await rm(join(root, "app/build.gradle.kts")); + await write("app/build.gradle", build.replace(/ = /g, " ").replaceAll('"', "'")); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("app/build.gradle")).toContain("minSdk 24"); + }); + + test("preserves a custom Kotlin Application and its startup work", async () => { + await write( + "app/src/main/AndroidManifest.xml", + manifest.replace(" { + await write( + "app/build.gradle.kts", + build.replace("minSdk = 23", 'minSdk = 23\n applicationIdSuffix = ".debug"'), + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("--android-package"); + const project = await inspectAndroidProject(root, { + androidPackage: "com.example.installed.debug", + }); + expect(project.packageName).toBe("com.example.installed.debug"); + }); + + test("does not mistake comments for variant configuration or namespace", async () => { + await write( + "app/build.gradle.kts", + `// applicationIdSuffix = ".fake"\n// namespace = "com.bad"\n${build}`, + ); + expect((await inspectAndroidProject(root)).packageName).toBe("com.example.installed"); + }); + + test("rejects computed application IDs instead of registering a namespace", async () => { + await write( + "app/build.gradle.kts", + build.replace( + 'applicationId = "com.example.installed"', + 'applicationId = providers.gradleProperty("appId").get()', + ), + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("--android-package"); + }); + + test("selects explicit modules in multi-module projects", async () => { + await write("other/build.gradle.kts", build); + await write("other/src/main/AndroidManifest.xml", manifest); + await expect(inspectAndroidProject(root)).rejects.toThrow("--android-module"); + expect((await inspectAndroidProject(root, { androidModule: "other" })).module).toBe("other"); + }); + + test("rejects symlinked module sources and escaping paths", async () => { + await symlink(join(root, "app"), join(root, "linked")); + await expect(inspectAndroidProject(root, { androidModule: "linked" })).rejects.toThrow( + "symlinks", + ); + await expect(inspectAndroidProject(root, { androidModule: "../outside" })).rejects.toThrow( + "Unsafe", + ); + }); + + test("rejects stale plans before any local writes", async () => { + const project = withPublishableKey(await inspectAndroidProject(root), key); + await write("app/build.gradle.kts", build + "// user edit\n"); + await expect(applyAndroidProject(project)).rejects.toThrow("changed after inspection"); + expect(await Bun.file(join(root, sourcePath)).exists()).toBe(false); + expect(await read("app/src/main/AndroidManifest.xml")).toBe(manifest); + }); + + test("refuses to overwrite a user-owned key resource", async () => { + await write("app/src/main/res/values/clerk.xml", ""); + await expect(inspectAndroidProject(root)).rejects.toThrow("not managed by Clerk"); + }); + + test("refuses existing unmanaged Clerk initialization", async () => { + await write( + "app/src/main/AndroidManifest.xml", + manifest.replace(" { + expect(normalizeFingerprints(["ab".repeat(32), Array(32).fill("AB").join(":")])).toEqual([ + Array(32).fill("AB").join(":"), + ]); + expect(() => normalizeFingerprints(["ab".repeat(20)])).toThrow("SHA-256"); + }); + + test("rejects secret keys, production keys and XML injection", async () => { + const project = await inspectAndroidProject(root); + for (const bad of ["sk_test_secret", "pk_live_prod", "pk_test_"]) { + expect(() => withPublishableKey(project, bad)).toThrow("development publishable key"); + } + }); +}); + +test("does not accept the string prefix of a computed ID", async () => { + await write( + "app/build.gradle.kts", + build.replace( + 'applicationId = "com.example.installed"', + 'applicationId = "com.example.installed" + suffix', + ), + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("--android-package"); +}); + +test("does not treat a commented SDK dependency as installed", async () => { + await write( + "app/build.gradle.kts", + build + '\n// implementation("com.clerk:clerk-android-api:0.1.0")\n', + ); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("app/build.gradle.kts")).toContain( + ' implementation("com.clerk:clerk-android-api:1.1.7")', + ); +}); + +test("reuses a version-catalog SDK entry", async () => { + await write( + "gradle/libs.versions.toml", + '[libraries]\nauth = { module = "com.clerk:clerk-android-api", version = "1.1.7" }\n', + ); + await write( + "app/build.gradle.kts", + build.replace('implementation("example:existing:1.0")', "implementation(libs.auth)"), + ); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("app/build.gradle.kts")).not.toContain("com.clerk:"); +}); + +test("supports version-catalog minSdk without modifying the shared minimum", async () => { + await write("gradle/libs.versions.toml", '[versions]\nminSdk = "23"\n'); + await write( + "app/build.gradle.kts", + build.replace("minSdk = 23", "minSdk = libs.versions.minSdk.get().toInt()"), + ); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("app/build.gradle.kts")).toContain("minSdk = 24"); + expect(await read("gradle/libs.versions.toml")).toContain('minSdk = "23"'); +}); + +test("upgrades a literal Kotlin plugin and keeps other declarations", async () => { + await write("app/build.gradle.kts", build.replace('version "2.4.20"', 'version "2.2.10"')); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("app/build.gradle.kts")).toContain('version "2.4.20"'); +}); + +test("plans a KGP classpath override for AGP built-in Kotlin and preserves imports", async () => { + await write( + "app/build.gradle.kts", + build.replace('; id("org.jetbrains.kotlin.android") version "2.4.20"', ""), + ); + await write( + "build.gradle.kts", + 'import java.util.Properties\nplugins { id("com.android.application") version "9.3.1" apply false }\n', + ); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + const rootBuild = await read("build.gradle.kts"); + expect(rootBuild).toStartWith("import java.util.Properties\n"); + expect(rootBuild).toContain("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.20"); + expect(withPublishableKey(await inspectAndroidProject(root), key).plan.actions).toHaveLength(0); +}); + +test("does not silently upgrade a compiler with KSP processors", async () => { + await write( + "app/build.gradle.kts", + build.replace('version "2.4.20"', 'version "2.2.10"') + '\nksp("example:processor:1.0")\n', + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("KSP/kapt"); +}); + +test("rejects duplicate or variant key resources", async () => { + await write( + "app/src/debug/res/values/strings.xml", + 'old', + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("resource already exists"); +}); + +test("upgrades a shared Kotlin catalog version and keeps Compose in sync", async () => { + await write( + "gradle/libs.versions.toml", + '[versions]\nkotlin = "2.2.10"\n[plugins]\nkotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }\nkotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }\n', + ); + await write( + "app/build.gradle.kts", + build.replace( + 'id("org.jetbrains.kotlin.android") version "2.4.20"', + "alias(libs.plugins.kotlin.android); alias(libs.plugins.kotlin.compose)", + ), + ); + await applyAndroidProject(withPublishableKey(await inspectAndroidProject(root), key)); + expect(await read("gradle/libs.versions.toml")).toContain('kotlin = "2.4.20"'); + expect(await read("gradle/libs.versions.toml")).toContain('version.ref = "kotlin"'); +}); + +test("refuses Kotlin source generation in a Java-only AGP 8 module", async () => { + await write( + "app/build.gradle.kts", + build.replace('; id("org.jetbrains.kotlin.android") version "2.4.20"', ""), + ); + await write( + "build.gradle.kts", + 'plugins { id("com.android.application") version "8.9.1" apply false }', + ); + await expect(inspectAndroidProject(root)).rejects.toThrow("must apply the Kotlin Android plugin"); +}); + +test("rolls back earlier edits if a later local write fails", async () => { + const project = withPublishableKey(await inspectAndroidProject(root), key); + await mkdir(join(root, sourcePath), { recursive: true }); + await expect(applyAndroidProject(project)).rejects.toThrow(); + expect(await read("app/build.gradle.kts")).toBe(build); + expect(await read("app/src/main/AndroidManifest.xml")).toBe(manifest); + expect(await Bun.file(join(root, "app/src/main/res/values/clerk.xml")).exists()).toBe(false); +}); diff --git a/packages/cli-core/src/commands/init/android/project.ts b/packages/cli-core/src/commands/init/android/project.ts new file mode 100644 index 000000000..7411740ee --- /dev/null +++ b/packages/cli-core/src/commands/init/android/project.ts @@ -0,0 +1,550 @@ +import { lstat, mkdir, readdir, unlink } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { CliError } from "../../../lib/errors.ts"; +import type { FileAction, ScaffoldPlan } from "../frameworks/types.ts"; + +const SDK_VERSION = "1.1.7"; +const KEY_PLACEHOLDER = "CLERK_DEVELOPMENT_PUBLISHABLE_KEY"; +const GENERATED = "Generated by Clerk CLI"; +const identifier = /^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/; + +export type AndroidOptions = { + androidModule?: string; + androidPackage?: string; + androidFingerprint?: string[]; + dryRun?: boolean; +}; +export type AndroidProject = { + root: string; + module: string; + packageName: string; + fingerprints: string[]; + plan: ScaffoldPlan; + originals: Map; +}; + +function unsupported(reason: string): never { + throw new CliError( + `${reason} No Android project files were changed. See https://clerk.com/docs/android/getting-started/quickstart for manual setup.`, + ); +} + +export function normalizeFingerprints(values: string[]): string[] { + return [ + ...new Set( + values.map((value) => { + const compact = value.trim().replaceAll(":", ""); + if (!/^[a-fA-F0-9]{64}$/.test(compact)) + throw new CliError( + "--android-fingerprint must be a SHA-256 signing certificate fingerprint (64 hex digits, optionally separated by colons).", + ); + return compact.toUpperCase().match(/../g)!.join(":"); + }), + ), + ].sort(); +} + +// Keep offsets stable while hiding comments and strings from structural scans. +export function maskCode(source: string, strings = true): string { + return source.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|"""[\s\S]*?"""|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, + (text) => (!strings && !text.startsWith("/") ? text : text.replace(/[^\r\n]/g, " ")), + ); +} + +function blocks(source: string, name: string): { open: number; close: number }[] { + const masked = maskCode(source); + const result = []; + for (const match of masked.matchAll(new RegExp(`\\b${name}\\s*\\{`, "g"))) { + const open = masked.indexOf("{", match.index); + let depth = 1; + let close = open; + while (depth && ++close < masked.length) { + if (masked[close] === "{") depth++; + if (masked[close] === "}") depth--; + } + if (depth) unsupported("Unbalanced Kotlin/Gradle block."); + result.push({ open, close }); + } + return result; +} + +function literal(source: string, name: string): string | undefined { + const masked = maskCode(source); + if ([...masked.matchAll(new RegExp(`\\b${name}\\b`, "g"))].length !== 1) return undefined; + const matches = [ + ...source.matchAll(new RegExp(`\\b${name}\\s*(?:=\\s*)?["']([^"'\\r\\n]+)["']`, "g")), + ].filter((match) => masked.slice(match.index, match.index + name.length) === name); + if (matches.length !== 1) return undefined; + const match = matches[0]!; + const rest = maskCode(source.slice(match.index + match[0].length)); + if (!/^[ \t]*(?:$|[\r\n;}])/.test(rest)) return undefined; + return match[1]; +} + +function hasCatalogSdk(build: string, catalog: string | null): boolean { + if (!catalog) return false; + const parsed = Bun.TOML.parse(catalog) as { libraries?: Record }; + return Object.entries(parsed.libraries ?? {}).some(([alias, value]) => { + const entry = value as { module?: string; group?: string; name?: string }; + const coordinate = + typeof value === "string" ? value : (entry.module ?? `${entry.group}:${entry.name}`); + const accessor = `libs.${alias.replace(/[-_]/g, ".")}`; + return ( + /^com\.clerk:clerk-android-(api|ui)(:|$)/.test(coordinate) && + maskCode(build).includes(`implementation(${accessor})`) + ); + }); +} + +async function assertSafePath(root: string, path: string): Promise { + const target = resolve(root, path); + const rel = relative(root, target); + if (!rel || rel.startsWith("..") || rel.startsWith("/")) + unsupported(`Unsafe Android path: ${path}.`); + let current = root; + for (const part of rel.split("/")) { + current = join(current, part); + const stat = await lstat(current).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (stat?.isSymbolicLink()) unsupported(`Android setup does not follow symlinks: ${path}.`); + } +} + +async function read(project: AndroidProject, path: string): Promise { + await assertSafePath(project.root, path); + const file = Bun.file(join(project.root, path)); + const content = (await file.exists()) ? await file.text() : null; + project.originals.set(path, content); + return content; +} + +function change(project: AndroidProject, path: string, content: string, description: string): void { + const old = project.originals.get(path); + if (old === content) return; + project.plan.actions.push({ + type: old == null ? "create" : "modify", + path, + content, + description, + }); +} + +async function findModules(root: string): Promise { + const found: string[] = []; + async function visit(dir: string, depth: number): Promise { + if (depth > 4) return; + const entries = await readdir(join(root, dir), { withFileTypes: true }); + if ( + entries.some((entry) => entry.isFile() && /^build\.gradle(?:\.kts)?$/.test(entry.name)) && + (await Bun.file(join(root, dir, "src/main/AndroidManifest.xml")).exists()) + ) + found.push(dir || "."); + for (const entry of entries) { + if ( + entry.isDirectory() && + !entry.name.startsWith(".") && + !["build", "node_modules", "gradle"].includes(entry.name) + ) { + await visit(join(dir, entry.name), depth + 1); + } + } + } + await visit("", 0); + return found; +} + +// Kotlin 2.4 metadata in SDK 1.1.7 requires a compatible compiler. Keep the +// common version catalog / literal plugin layouts in sync; custom build logic +// and processor migrations need the project's owner to select compatible versions. +async function planKotlinToolchain( + project: AndroidProject, + modulePath: string, + moduleBuild: string, + catalog: string | null, +): Promise { + const minimum = "2.4.20"; + const rootPaths = ["build.gradle.kts", "build.gradle"].filter((path) => path !== modulePath); + const sources = new Map([[modulePath, moduleBuild]]); + for (const path of rootPaths) { + const source = await read(project, path); + if (source != null) sources.set(path, source); + } + const all = + [...sources.values()].map((source) => maskCode(source, false)).join("\n") + (catalog ?? ""); + const parsedPlugins = catalog + ? (Bun.TOML.parse(catalog) as { plugins?: Record }).plugins + : undefined; + const appliesKotlin = + /(?:org\.jetbrains\.kotlin\.android|kotlin-android|kotlin\s*\(\s*["']android["'])/.test( + maskCode(moduleBuild, false), + ) || + Object.entries(parsedPlugins ?? {}).some( + ([alias, plugin]) => + plugin.id === "org.jetbrains.kotlin.android" && + maskCode(moduleBuild).includes(`libs.plugins.${alias.replace(/[-_]/g, ".")}`), + ); + const agpVersion = + /(?:com\.android\.application["']\s*\)\s+version\s+["']|^agp\s*=\s*["'])(\d+)\./m.exec(all); + if (!appliesKotlin && !(agpVersion && Number(agpVersion[1]) >= 9)) + unsupported( + "The selected module must apply the Kotlin Android plugin or use AGP 9 built-in Kotlin to compile the generated Application.", + ); + const compatible = (version: string) => { + const [major, minor] = version.split(".").map(Number); + return major! > 2 || (major === 2 && minor! >= 4); + }; + const pluginVersion = + /(\b(?:id\s*\(\s*["']org\.jetbrains\.kotlin\.[\w.]+["']\s*\)|kotlin\s*\(\s*["'][\w.]+["']\s*\))\s+version\s+["'])(\d+\.\d+\.\d+)(["'])/g; + const hasOldPlugin = [...all.matchAll(pluginVersion)].some((m) => !compatible(m[2]!)); + const parsedCatalog = catalog + ? (Bun.TOML.parse(catalog) as { versions?: Record }) + : null; + const catalogMatch = parsedCatalog?.versions?.kotlin + ? /^kotlin\s*=\s*["'](\d+\.\d+\.\d+)["']/m.exec(catalog!) + : null; + const oldCatalog = catalogMatch && !compatible(catalogMatch[1]!); + if ((hasOldPlugin || oldCatalog) && /(?:ksp|kapt)/i.test(all)) + unsupported( + "Upgrade Kotlin to 2.4+ and choose compatible KSP/kapt processor versions before adding Clerk SDK 1.1.7.", + ); + if (oldCatalog) { + change( + project, + "gradle/libs.versions.toml", + catalog!.replace(catalogMatch[0], catalogMatch[0].replace(catalogMatch[1]!, minimum)), + "Upgrade Kotlin and shared Kotlin plugin versions for Clerk SDK compatibility", + ); + } + let hasCompiler = Boolean(catalogMatch); + for (const [path, source] of sources) { + const visible = maskCode(source, false); + const replacements = [...visible.matchAll(pluginVersion)]; + if (replacements.length) hasCompiler = true; + let updated = source; + for (const m of replacements.reverse()) { + if (compatible(m[2]!)) continue; + updated = + updated.slice(0, m.index) + m[1] + minimum + m[3] + updated.slice(m.index + m[0].length); + } + if (path === modulePath) moduleBuild = updated; + else + change(project, path, updated, "Upgrade Kotlin plugin versions for Clerk SDK compatibility"); + } + if (!hasCompiler) { + const classpathVersion = /org\.jetbrains\.kotlin:kotlin-gradle-plugin:(\d+\.\d+\.\d+)/.exec( + all, + ); + if (classpathVersion && compatible(classpathVersion[1]!)) return moduleBuild; + // AGP 9's built-in Kotlin can be upgraded by adding KGP to the root classpath. + const agp = + /(?:com\.android\.application["']\s*\)\s+version\s+["']|^agp\s*=\s*["'])(\d+)\./m.exec(all); + const rootPath = [...sources.keys()].find((path) => rootPaths.includes(path)); + if (!classpathVersion && agp && Number(agp[1]) >= 9 && rootPath && !/ksp|kapt/i.test(all)) { + const prefix = `buildscript {\n repositories { mavenCentral() }\n dependencies { classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${minimum}") }\n}\n\n`; + const rootSource = sources.get(rootPath)!; + const imports = [...maskCode(rootSource, false).matchAll(/^import[^\r\n]*[\r\n]+/gm)]; + const lastImport = imports.at(-1); + const at = lastImport ? lastImport.index + lastImport[0].length : 0; + change( + project, + rootPath, + rootSource.slice(0, at) + prefix + rootSource.slice(at), + "Use Kotlin 2.4 with AGP's built-in Kotlin for Clerk SDK compatibility", + ); + } else { + unsupported( + "Cannot verify a compatible Kotlin compiler. Declare Kotlin 2.4+ in the root plugins block or the kotlin version-catalog entry before automatic setup.", + ); + } + } + return moduleBuild; +} + +export async function inspectAndroidProject( + root: string, + options: AndroidOptions = {}, +): Promise { + const fingerprints = normalizeFingerprints(options.androidFingerprint ?? []); + if (options.androidPackage && !identifier.test(options.androidPackage)) + unsupported( + "Invalid --android-package. Use the installed application's applicationId, including any variant suffix.", + ); + const modules = options.androidModule ? [options.androidModule] : await findModules(root); + if (modules.length !== 1) + unsupported("Select one Android application module with --android-module ."); + const module = modules[0]!; + const project: AndroidProject = { + root: resolve(root), + module, + packageName: "", + fingerprints, + plan: { actions: [], postInstructions: [] }, + originals: new Map(), + }; + const kotlinBuild = join(module, "build.gradle.kts"); + const groovyBuild = join(module, "build.gradle"); + const kts = await read(project, kotlinBuild); + const groovy = await read(project, groovyBuild); + if ((kts == null) === (groovy == null)) + unsupported("Expected exactly one build.gradle.kts or build.gradle in the selected module."); + const buildPath = kts != null ? kotlinBuild : groovyBuild; + const build = (kts ?? groovy)!; + const android = blocks(build, "android"); + const config = blocks(build, "defaultConfig"); + if ( + android.length !== 1 || + config.length !== 1 || + config[0]!.open < android[0]!.open || + config[0]!.close > android[0]!.close + ) + unsupported("Cannot locate a single android.defaultConfig block."); + const namespace = literal(build.slice(android[0]!.open, android[0]!.close), "namespace"); + if (!namespace || !identifier.test(namespace)) + unsupported("Android namespace must be a literal package name for automatic Kotlin setup."); + const defaultConfig = build.slice(config[0]!.open, config[0]!.close); + const packageName = options.androidPackage ?? literal(defaultConfig, "applicationId"); + const masked = maskCode(build); + if ( + !options.androidPackage && + (/\b(?:applicationIdSuffix|productFlavors|apply|androidComponents)\b/.test(masked) || + [...masked.matchAll(/\bapplicationId\b/g)].length !== 1) + ) + unsupported( + "This build customizes its application ID or applies external build logic. Pass --android-package with the final applicationId for the installed variant.", + ); + if (!packageName || !identifier.test(packageName)) + unsupported( + "Cannot determine the Android applicationId. Pass --android-package explicitly; the Gradle namespace is not the applicationId.", + ); + project.packageName = packageName; + let updatedBuild = build; + const catalog = await read(project, "gradle/libs.versions.toml"); + const minSdkMatch = + /\bminSdk(?:Version)?\s*(?:=\s*)?(\d+|libs\.versions\.([\w.]+)\.get\(\)\.toInt\(\))/.exec( + maskCode(defaultConfig), + ); + const versions = catalog + ? (Bun.TOML.parse(catalog) as { versions?: Record }).versions + : undefined; + const minSdk = minSdkMatch?.[2] + ? versions?.[minSdkMatch[2].replaceAll(".", "-")] + : minSdkMatch?.[1]; + if (!minSdkMatch || !minSdk || !/^\d+$/.test(minSdk)) + unsupported( + "Set a literal or version-catalog minSdk of at least 24 in defaultConfig before automatic Android setup.", + ); + if (Number(minSdk) < 24) { + const index = config[0]!.open + minSdkMatch.index; + updatedBuild = + updatedBuild.slice(0, index) + + minSdkMatch[0].replace(minSdkMatch[1]!, "24") + + updatedBuild.slice(index + minSdkMatch[0].length); + } + // A pre-existing Clerk dependency may be a catalog alias or local project. + const hasClerk = + /\bimplementation\s*\(?\s*["']com\.clerk:clerk-android-(?:api|ui):/.test( + maskCode(build, false), + ) || hasCatalogSdk(build, catalog); + if (catalog?.includes("com.clerk") && !hasClerk) + project.plan.postInstructions.push( + "Check any existing Clerk version-catalog entry; this setup adds a direct SDK dependency.", + ); + if (!hasClerk) { + const deps = blocks(updatedBuild, "dependencies").filter((block) => { + const prefix = maskCode(updatedBuild.slice(0, block.open)); + return ( + [...prefix].filter((c) => c === "{").length === [...prefix].filter((c) => c === "}").length + ); + }); + const dependency = `\n implementation("com.clerk:clerk-android-api:${SDK_VERSION}")`; + if (deps.length > 1) + unsupported("Multiple top-level dependencies blocks require manual SDK installation."); + updatedBuild = deps[0] + ? updatedBuild.slice(0, deps[0].open + 1) + dependency + updatedBuild.slice(deps[0].open + 1) + : updatedBuild + `\n\ndependencies {${dependency}\n}\n`; + } + updatedBuild = await planKotlinToolchain(project, buildPath, updatedBuild, catalog); + change(project, buildPath, updatedBuild, "Install the Clerk API SDK and require minSdk 24+"); + + const manifestPath = join(module, "src/main/AndroidManifest.xml"); + const manifest = await read(project, manifestPath); + if (!manifest || //g, (comment) => " ".repeat(comment.length)); + if (!/xmlns:android\s*=\s*["']http:\/\/schemas.android.com\/apk\/res\/android["']/.test(xml)) + unsupported("AndroidManifest.xml must declare the android XML namespace."); + const apps = [...xml.matchAll(/]*>/g)]; + if (apps.length !== 1) + unsupported("Expected a single application element in AndroidManifest.xml."); + const app = apps[0]!; + const nameMatch = /android:name\s*=\s*["']([^"']+)["']/.exec(app[0]); + const appName = nameMatch?.[1]; + if (appName && !/^\.?[\w.]+$/.test(appName)) + unsupported("The manifest Application class must be a literal Kotlin class name."); + const className = appName + ? appName.startsWith(".") + ? namespace + appName + : appName.includes(".") + ? appName + : `${namespace}.${appName}` + : `${namespace}.ClerkApplication`; + const sourcePath = className.replaceAll(".", "/") + ".kt"; + const kotlinPath = join(module, "src/main/kotlin", sourcePath); + const javaPath = join(module, "src/main/java", sourcePath); + const kotlin = await read(project, kotlinPath); + const java = await read(project, javaPath); + if (kotlin != null && java != null) + unsupported("Duplicate Kotlin Application classes require manual setup."); + const source = kotlin ?? java; + const path = java != null ? javaPath : kotlinPath; + if (!appName && source != null) + unsupported( + `The unregistered ${className} source already exists. Register it in the manifest before running init.`, + ); + const init = `com.clerk.api.Clerk.initialize(this, publishableKey = getString(${namespace}.R.string.clerk_publishable_key))`; + if (source == null) { + if (appName) + unsupported( + `Cannot find Kotlin source for ${className}. Initialize Clerk in the existing Application manually.`, + ); + change( + project, + path, + `package ${namespace}\n\nimport android.app.Application\n\n// ${GENERATED}\nclass ClerkApplication : Application() {\n override fun onCreate() {\n super.onCreate()\n ${init}\n }\n}\n`, + "Initialize Clerk when the Android application starts", + ); + } else if (maskCode(source).includes(init)) { + // Our generated runtime sink already exists. Only the key resource may need work. + } else { + if (/\bClerk\s*\.\s*(?:initialize|configure)\b/.test(source)) + unsupported( + "This Application already configures Clerk. Keep its existing initialization and configure native registration manually.", + ); + const onCreates = [...maskCode(source).matchAll(/\boverride\s+fun\s+onCreate\s*\(\s*\)\s*\{/g)]; + const supers = [...maskCode(source).matchAll(/\bsuper\.onCreate\(\)/g)]; + if (onCreates.length !== 1 || supers.length !== 1 || supers[0]!.index < onCreates[0]!.index) + unsupported( + "Automatic setup requires a Kotlin Application with one onCreate override calling super.onCreate().", + ); + const end = supers[0]!.index + supers[0]![0].length; + if (!/^[ \t]*(?:$|[\r\n;}])/.test(maskCode(source.slice(end)))) + unsupported("The Application super.onCreate call must be a standalone statement."); + change( + project, + path, + source.slice(0, end) + `\n ${init}` + source.slice(end), + "Initialize Clerk after Application.onCreate", + ); + } + let nextManifest = manifest; + if (!appName) { + const at = app.index + "]*android:name\s*=\s*["']android.permission.INTERNET["']/.test(xml)) { + nextManifest = nextManifest.replace( + /(]*>)/, + '$1\n ', + ); + } + change( + project, + manifestPath, + nextManifest, + "Register the Application class and internet permission", + ); + const resourcePath = join(module, "src/main/res/values/clerk.xml"); + const resource = await read(project, resourcePath); + if ( + resource != null && + (!resource.includes(``) || + [...resource.matchAll(/pk_test_[A-Za-z0-9+/=_-]+<\/string>/.test( + resource, + )) + ) + unsupported(`${resourcePath} already exists and is not managed by Clerk.`); + for await (const path of new Bun.Glob(`${module}/src/**/res/values*/*.xml`).scan({ + cwd: project.root, + onlyFiles: true, + followSymlinks: false, + })) { + if (path === resourcePath) continue; + const value = await read(project, path); + if (/name\s*=\s*["']clerk_publishable_key["']/.test(value ?? "")) + unsupported( + `A clerk_publishable_key resource already exists in ${path}. Preserve it and initialize Clerk manually.`, + ); + } + change( + project, + resourcePath, + `\n\n \n ${KEY_PLACEHOLDER}\n\n`, + "Configure the development publishable key (no secret key)", + ); + project.plan.postInstructions.push( + "Sync Gradle with Java 17 or newer, then build and run the app. The Clerk API SDK is initialized; add your authentication UI using the Android quickstart.", + ); + if (!fingerprints.length) + project.plan.postInstructions.push( + "Pass --android-fingerprint from ./gradlew signingReport to enable passkey association. Include the certificate used to sign the installed app.", + ); + return project; +} + +export function withPublishableKey(project: AndroidProject, key: string): AndroidProject { + if (!/^pk_test_[A-Za-z0-9+/=_-]+$/.test(key)) + throw new CliError("Android init requires a development publishable key."); + return { + ...project, + plan: { + ...project.plan, + actions: project.plan.actions.flatMap((action): FileAction[] => { + if (action.type === "skip") return [action]; + const content = action.content.replace(KEY_PLACEHOLDER, key); + return content === project.originals.get(action.path) ? [] : [{ ...action, content }]; + }), + }, + }; +} + +export async function assertProjectUnchanged(project: AndroidProject): Promise { + for (const [path, original] of project.originals) { + await assertSafePath(project.root, path); + const file = Bun.file(join(project.root, path)); + const current = (await file.exists()) ? await file.text() : null; + if (current !== original) + throw new CliError( + `Android setup stopped because ${path} changed after inspection. Run init again.`, + ); + } +} + +export async function applyAndroidProject(project: AndroidProject): Promise { + await assertProjectUnchanged(project); + const written: Exclude[] = []; + try { + for (const action of project.plan.actions) { + if (action.type === "skip") continue; + await assertSafePath(project.root, action.path); + const path = join(project.root, action.path); + await mkdir(dirname(path), { recursive: true }); + written.push(action); + await Bun.write(path, action.content); + } + } catch (error) { + for (const action of written.reverse()) { + await assertSafePath(project.root, action.path); + const file = Bun.file(join(project.root, action.path)); + if ((await file.exists()) && (await file.text()) === action.content) { + const original = project.originals.get(action.path); + if (original == null) await unlink(join(project.root, action.path)); + else await Bun.write(file, original); + } + } + throw error; + } +} diff --git a/packages/cli-core/src/commands/init/android/setup.test.ts b/packages/cli-core/src/commands/init/android/setup.test.ts new file mode 100644 index 000000000..68d1c3373 --- /dev/null +++ b/packages/cli-core/src/commands/init/android/setup.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stubFetch, useCaptureLog } from "../../../test/lib/stubs.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { inspectAndroidProject } from "./project.ts"; +import { setupAndroid } from "./setup.ts"; + +let root: string; +const originalEnv = { ...process.env }; +const originalFetch = globalThis.fetch; +const key = "pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk"; +const fingerprint = Array(32).fill("AB").join(":"); +const logs = useCaptureLog(); +let apps: { + object: string; + id: string; + namespace: string; + package_name: string; + fingerprints: string[]; +}[]; +let enabled: boolean; +let requests: { method: string; path: string; body: unknown; headers: Headers }[]; +let failPost: boolean; +let lostPost: boolean; +let malformed: boolean; +let permissionDenied: boolean; +let prodOnly: boolean; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "clerk-android-setup-")); + process.env.CLERK_PLATFORM_API_KEY = "ak_test"; + process.env.CLERK_TELEMETRY_DISABLED = "1"; + apps = []; + enabled = false; + requests = []; + failPost = false; + lostPost = false; + malformed = false; + permissionDenied = false; + prodOnly = false; + await Bun.write( + join(root, "app/build.gradle.kts"), + 'plugins { id("org.jetbrains.kotlin.android") version "2.4.20" }\nandroid {\n namespace = "com.example.app"\n defaultConfig {\n applicationId = "com.example.app"\n minSdk = 24\n }\n}\n', + ); + await Bun.write( + join(root, "app/src/main/AndroidManifest.xml"), + '', + ); + stubFetch(async (input, init) => { + const url = new URL(input.toString()); + const method = init?.method ?? "GET"; + const body = init?.body ? JSON.parse(String(init.body)) : null; + requests.push({ + method, + path: url.pathname + url.search, + body, + headers: new Headers(init?.headers), + }); + if (permissionDenied) + return Response.json({ errors: [{ message: "Forbidden" }] }, { status: 403 }); + if (url.pathname === "/v1/platform/applications/app_test") { + return Response.json({ + application_id: "app_test", + instances: [ + { + instance_id: "ins_dev", + environment_type: prodOnly ? "production" : "development", + publishable_key: key, + }, + ], + }); + } + if (url.pathname.endsWith("/native_settings")) { + if (method === "PATCH") enabled = true; + return Response.json(malformed ? {} : { object: "native_settings", api_enabled: enabled }); + } + if (url.pathname.endsWith("/native_applications/android")) { + if (method === "GET") return Response.json(apps); + if (failPost) return Response.json({ errors: [{ message: "Unavailable" }] }, { status: 500 }); + const app = { object: "android_application", id: "android_1", ...body }; + apps.push(app); + if (lostPost) throw new TypeError("Connection lost after commit"); + return Response.json(app, { status: 201 }); + } + throw new Error(`Unexpected request: ${method} ${url}`); + }); +}); +afterEach(async () => { + globalThis.fetch = originalFetch; + process.env = { ...originalEnv }; + await rm(root, { recursive: true, force: true }); +}); + +const inspect = async () => inspectAndroidProject(root, { androidFingerprint: [fingerprint] }); +const options = { app: "app_test", skipConfirm: true }; +const resource = async () => Bun.file(join(root, "app/src/main/res/values/clerk.xml")).exists(); + +test("enables native API, registers the installed package and writes only the public key", async () => { + await setupAndroid(await inspect(), options); + expect(enabled).toBe(true); + expect(apps).toHaveLength(1); + expect(apps[0]?.fingerprints).toEqual([fingerprint]); + expect(requests.find((r) => r.method === "PATCH")?.body).toEqual({ api_enabled: true }); + const post = requests.find((r) => r.method === "POST")!; + expect(post.body).toEqual({ + namespace: "android_app", + package_name: "com.example.app", + fingerprints: [fingerprint], + }); + expect(post.headers.get("idempotency-key")).toMatch(/^android-init-[a-f0-9]{64}$/); + expect(post.headers.get("authorization")).toBe("Bearer ak_test"); + expect(requests[0]?.path).toContain("include_secret_keys=false"); + expect(await resource()).toBe(true); + expect(logs.out + logs.err).not.toContain(key); + expect(await Bun.file(join(root, ".env")).exists()).toBe(false); +}); + +test("rerunning reuses the registration and makes no API mutations", async () => { + await setupAndroid(await inspect(), options); + requests = []; + await setupAndroid(await inspect(), options); + expect(requests.every((r) => r.method === "GET")).toBe(true); + expect(apps).toHaveLength(1); + expect(logs.out + logs.err).toContain("already set up"); +}); + +test("missing fingerprints on an existing registration block before enabling or local writes", async () => { + apps = [ + { + object: "android_application", + id: "android_1", + namespace: "android_app", + package_name: "com.example.app", + fingerprints: [], + }, + ]; + await expect(setupAndroid(await inspect(), options)).rejects.toThrow( + "missing requested signing fingerprints", + ); + expect(enabled).toBe(false); + expect(await resource()).toBe(false); + expect(requests.every((r) => r.method === "GET")).toBe(true); +}); + +test("existing registration can have additional signing certificates", async () => { + apps = [ + { + object: "android_application", + id: "android_1", + namespace: "android_app", + package_name: "com.example.app", + fingerprints: [fingerprint, "CD".repeat(32)], + }, + ]; + await setupAndroid(await inspect(), options); + expect(requests.some((r) => r.method === "POST")).toBe(false); +}); + +test("failed registration preserves project files and retries with the same idempotency key", async () => { + failPost = true; + await expect(setupAndroid(await inspect(), options)).rejects.toThrow(); + expect(await resource()).toBe(false); + const firstKey = requests.find((r) => r.method === "POST")!.headers.get("idempotency-key"); + requests = []; + failPost = false; + await setupAndroid(await inspect(), options); + expect(requests.find((r) => r.method === "POST")!.headers.get("idempotency-key")).toBe(firstKey); +}); + +test("recovers a committed registration after a lost response", async () => { + lostPost = true; + await setupAndroid(await inspect(), options); + expect(apps).toHaveLength(1); + expect(await resource()).toBe(true); +}); + +test("rejects malformed native settings without mutations", async () => { + malformed = true; + await expect(setupAndroid(await inspect(), options)).rejects.toThrow( + "Unexpected Native API settings", + ); + expect(requests.every((r) => r.method === "GET")).toBe(true); + expect(await resource()).toBe(false); +}); + +test("does not fall back to production", async () => { + prodOnly = true; + await expect(setupAndroid(await inspect(), options)).rejects.toThrow("development instance"); + expect(requests).toHaveLength(1); + expect(await resource()).toBe(false); +}); + +test("permission failure leaves the project unchanged", async () => { + permissionDenied = true; + await expect(setupAndroid(await inspect(), options)).rejects.toThrow(); + expect(await resource()).toBe(false); +}); + +test("cancelled confirmation makes no remote or local mutations", async () => { + const confirm = spyOn(prompts, "confirm").mockResolvedValue(false); + try { + await expect( + setupAndroid(await inspect(), { ...options, skipConfirm: false }), + ).rejects.toThrow(); + expect(requests.every((r) => r.method === "GET")).toBe(true); + expect(await resource()).toBe(false); + } finally { + confirm.mockRestore(); + } +}); + +test("a stale local plan blocks remote writes", async () => { + const project = await inspect(); + await Bun.write(join(root, "app/build.gradle.kts"), "// changed by user"); + await expect(setupAndroid(project, options)).rejects.toThrow("changed after inspection"); + expect(requests.every((r) => r.method === "GET")).toBe(true); +}); diff --git a/packages/cli-core/src/commands/init/android/setup.ts b/packages/cli-core/src/commands/init/android/setup.ts new file mode 100644 index 000000000..71c02703a --- /dev/null +++ b/packages/cli-core/src/commands/init/android/setup.ts @@ -0,0 +1,132 @@ +import { createHash } from "node:crypto"; +import { resolveAppContext } from "../../../lib/config.ts"; +import { CliError, PlapiError, throwUserAbort } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { + createAndroidApplication, + enableNativeApi, + fetchApplication, + getNativeSettings, + listAndroidApplications, + type AndroidApplication, +} from "../../../lib/plapi.ts"; +import { previewPlan } from "../preview.ts"; +import { + applyAndroidProject, + assertProjectUnchanged, + normalizeFingerprints, + withPublishableKey, + type AndroidProject, +} from "./project.ts"; + +function matchingRegistration( + apps: AndroidApplication[], + project: AndroidProject, +): AndroidApplication | undefined { + const matches = apps.filter((app) => app.package_name === project.packageName); + if (matches.length > 1) + throw new CliError( + "Multiple Android registrations matched the applicationId. Review Native Applications in the Dashboard.", + ); + const app = matches[0]; + if ( + app && + (app.namespace !== "android_app" || + !project.fingerprints.every((fp) => normalizeFingerprints(app.fingerprints).includes(fp))) + ) { + throw new CliError( + "The existing Android registration has a different namespace or is missing requested signing fingerprints. Update it in Dashboard > Native Applications, then rerun init. Local project files were not changed.", + ); + } + return app; +} + +export async function setupAndroid( + project: AndroidProject, + options: { app?: string; skipConfirm: boolean }, +): Promise { + const target = options.app + ? { appId: options.app, instanceId: undefined } + : await resolveAppContext({ + cwd: project.root, + app: options.app, + instance: "development", + }); + // Confirm the instance using authoritative application metadata, not a stale saved profile. + const application = await fetchApplication(target.appId, { includeSecretKeys: false }); + const instance = application.instances.find((entry) => entry.environment_type === "development"); + if (!instance || (target.instanceId && instance.instance_id !== target.instanceId)) + throw new CliError( + "The linked development instance changed. Run clerk link again before Android setup.", + ); + const prepared = withPublishableKey(project, instance.publishable_key); + const [settings, registrations] = await Promise.all([ + getNativeSettings(target.appId, instance.instance_id), + listAndroidApplications(target.appId, instance.instance_id), + ]); + const existing = matchingRegistration(registrations, project); + log.info( + `Android setup: ${project.packageName} → ${target.appId} / development (${instance.instance_id})`, + ); + log.info(settings.api_enabled ? "Native API is already enabled." : "Enable Native API."); + log.info( + existing + ? "Reuse the existing Android registration." + : `Register Android application with ${project.fingerprints.length} signing fingerprint(s).`, + ); + previewPlan(prepared.plan); + const hasChanges = !settings.api_enabled || !existing || prepared.plan.actions.length > 0; + if (hasChanges && !options.skipConfirm && !(await confirm({ message: "Apply Android setup?" }))) + throwUserAbort(); + await assertProjectUnchanged(prepared); + if (!settings.api_enabled) { + const enabled = await enableNativeApi(target.appId, instance.instance_id); + if (!enabled.api_enabled) + throw new CliError( + "Native API was not enabled. Android setup stopped before registration or local writes.", + ); + } + if (!existing) { + const params = { + namespace: "android_app" as const, + package_name: project.packageName, + fingerprints: project.fingerprints, + }; + const key = createHash("sha256") + .update(JSON.stringify([target.appId, instance.instance_id, params])) + .digest("hex"); + try { + const created = await createAndroidApplication( + target.appId, + instance.instance_id, + params, + `android-init-${key}`, + ); + if (!matchingRegistration([created], project)) + throw new CliError( + "Android registration returned a different applicationId. Local setup was not applied.", + ); + } catch (error) { + // A concurrent creator or a lost successful response can leave the row in + // place. Reconcile it; never overwrite or silently change a registration. + if ( + !(error instanceof TypeError) && + !(error instanceof PlapiError && (error.status === 422 || error.status >= 500)) + ) + throw error; + const current = await listAndroidApplications(target.appId, instance.instance_id); + if (!matchingRegistration(current, project)) throw error; + } + } + try { + await applyAndroidProject(prepared); + } catch (error) { + log.warn( + "Native settings/registration may already be saved. Local changes were rolled back where possible; rerun init to finish setup.", + ); + throw error; + } + log.success(hasChanges ? "Android setup complete." : "Android is already set up."); + for (const instruction of prepared.plan.postInstructions) log.info(instruction); +} diff --git a/packages/cli-core/src/commands/init/index-android.test.ts b/packages/cli-core/src/commands/init/index-android.test.ts new file mode 100644 index 000000000..102a847f5 --- /dev/null +++ b/packages/cli-core/src/commands/init/index-android.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + useInitHarness, + FAKE_CTX, + loginMod, + linkMod, + pullMod, + heuristics, +} from "../../test/lib/init-harness.ts"; +import * as android from "./android/setup.ts"; +import { init } from "./index.ts"; + +describe("Android init orchestration", () => { + const harness = useInitHarness(); + let root: string; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "clerk-init-android-")); + await Bun.write( + join(root, "app/build.gradle.kts"), + 'plugins { id("org.jetbrains.kotlin.android") version "2.4.20" }\nandroid {\n namespace = "com.example.app"\n defaultConfig {\n applicationId = "com.example.app"\n minSdk = 24\n }\n}', + ); + await Bun.write( + join(root, "app/src/main/AndroidManifest.xml"), + '', + ); + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + const context = () => ({ + ...FAKE_CTX, + cwd: root, + framework: { + dep: "android", + name: "Android (Kotlin)", + sdk: "com.clerk:clerk-android-api", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "gradle" as const, + }, + }); + + test("dry run does not authenticate, link, fetch keys or apply setup", async () => { + harness.setup().gatherContextSpy.mockResolvedValue(context()); + const setup = spyOn(android, "setupAndroid").mockResolvedValue(); + harness.track(setup); + await init({ dryRun: true, app: "app_1" }); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(heuristics.isAuthenticated).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(setup).not.toHaveBeenCalled(); + expect(await Bun.file(join(root, "app/src/main/res/values/clerk.xml")).exists()).toBe(false); + }); + + test("authenticated Android init uses native setup instead of generic env pull", async () => { + harness.setup({ apiKey: true }).gatherContextSpy.mockResolvedValue(context()); + const setup = spyOn(android, "setupAndroid").mockResolvedValue(); + harness.track(setup); + await init({ app: "app_1", yes: true }); + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ packageName: "com.example.app" }), + { app: "app_1", skipConfirm: true }, + ); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(heuristics.installSdk).not.toHaveBeenCalled(); + }); + + test("an unauthenticated agent without a target previews setup without applying it", async () => { + harness.setup({ isAgent: true }).gatherContextSpy.mockResolvedValue(context()); + const setup = spyOn(android, "setupAndroid").mockResolvedValue(); + harness.track(setup); + await init(); + expect(setup).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(harness.captured.out + harness.captured.err).toContain("--app "); + }); + + test("Android-only flags fail on other frameworks before linking", async () => { + harness.setup().gatherContextSpy.mockResolvedValue(FAKE_CTX); + await expect(init({ androidPackage: "com.example.app" })).rejects.toThrow("only to Android"); + expect(linkMod.link).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 10f8e7ef1..2f2669110 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -61,8 +61,10 @@ import { } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; +import { inspectAndroidProject, type AndroidOptions } from "./android/project.ts"; +import { setupAndroid } from "./android/setup.ts"; -type InitOptions = { +type InitOptions = AndroidOptions & { /** Framework to set up (skips auto-detection). */ framework?: string; pm?: PackageManager; @@ -95,10 +97,34 @@ export async function init(options: InitOptions = {}) { if (options.keyless) { log.warn("`--keyless` is deprecated. Use `--accountless` instead."); } - await assertUsableFlags(options, agent, optsAccountless); - - const frameworkOverride = options.framework - ? (lookupFramework(options.framework) ?? undefined) + if ( + options.dryRun && + (options.starter || optsAccountless || options.login || options.template || options.fresh) + ) { + throwUsageError( + "--dry-run is for inspecting an existing Android project and cannot be combined with starter or authentication options.", + ); + } + if ( + options.starter && + (options.androidModule || + options.androidPackage || + options.androidFingerprint || + options.framework === "android") + ) { + throwUsageError( + "Android setup requires an existing application module; --starter is not supported.", + ); + } + if (!options.dryRun) await assertUsableFlags(options, agent, optsAccountless); + + const frameworkName = + options.framework ?? + (options.androidModule || options.androidPackage || options.androidFingerprint + ? "android" + : undefined); + const frameworkOverride = frameworkName + ? (lookupFramework(frameworkName) ?? undefined) : undefined; // In agent mode, implicitly enable --yes to skip all confirmation prompts. @@ -111,14 +137,39 @@ export async function init(options: InitOptions = {}) { intro("Setting up Clerk"); setTelemetryStage("detect"); - const resolved = options.starter - ? await handleStarter(cwd, frameworkOverride, overrides) - : await resolveProjectContext(cwd, frameworkOverride, overrides); + const resolved = options.dryRun + ? { ctx: await gatherContext(cwd, frameworkOverride), bootstrap: null } + : options.starter + ? await handleStarter(cwd, frameworkOverride, overrides) + : await resolveProjectContext(cwd, frameworkOverride, overrides); if (!resolved) return; + if (!resolved.ctx) throwUsageError("Could not detect an Android project for --dry-run."); const { ctx, bootstrap } = resolved; + const hasAndroidFlags = + options.androidModule !== undefined || + options.androidPackage !== undefined || + options.androidFingerprint !== undefined || + options.dryRun; + if (hasAndroidFlags && ctx.framework.dep !== "android") { + throwUsageError( + "--android-module, --android-package, --android-fingerprint and --dry-run apply only to Android projects.", + ); + } + const androidProject = + ctx.framework.dep === "android" ? await inspectAndroidProject(ctx.cwd, options) : undefined; + if (options.dryRun && androidProject) { + log.info(`Android applicationId: ${androidProject.packageName}`); + previewPlan(androidProject.plan); + log.info( + "Dry run: no authentication, API requests, or file writes. A normal run also enables Native API and registers this application on the linked development instance.", + ); + await outro("Done"); + return; + } + if (bootstrap) { ctx.isBootstrap = true; } @@ -164,6 +215,22 @@ export async function init(options: InitOptions = {}) { await authenticateAndLink(ctx.cwd, options.app, createIfMissing); } + if (androidProject) { + if (strategy === "authenticate") { + await setupAndroid(androidProject, { + app: options.app, + skipConfirm: Boolean(overrides.skipConfirm), + }); + } else { + previewPlan(androidProject.plan); + log.info( + "Run clerk init --app to apply Android setup with your development publishable key.", + ); + } + await outro("Done"); + return; + } + // Short-circuit on a fully-clean re-run so env pull / skills prompt don't // execute when there's nothing to do. // Bootstrap implies consent — the user already opted into project creation, so @@ -691,6 +758,13 @@ export function registerInit(program: Program): void { ) .option("--name ", "Project name for --starter (skips prompt)") .option("--app ", "Application ID to link (skips interactive picker)") + .option("--android-module ", "Android application module relative to the project root") + .option("--android-package ", "Final Android applicationId, including any variant suffix") + .option( + "--android-fingerprint ", + "SHA-256 signing certificate fingerprints for Android passkeys", + ) + .option("--dry-run", "Preview Android setup without authentication, API calls, or file writes") .option("--starter", "Create a new project from a starter template") .option( "--accountless", diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index ba8f80546..1af49d8dd 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,13 +69,18 @@ export async function getAuthToken(): Promise { * throws PlapiError on non-ok responses. Debug logging is centralized in * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ -async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { +async function plapiFetch( + method: string, + url: URL, + init?: { body?: string; idempotencyKey?: string }, +): Promise { const token = await getAuthToken(); const headers: Record = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (init?.body) headers["Content-Type"] = "application/json"; + if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; const response = await loggedFetch(url, { tag: "plapi", method, @@ -229,9 +234,12 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -export async function fetchApplication(applicationId: string): Promise { +export async function fetchApplication( + applicationId: string, + options?: { includeSecretKeys?: boolean }, +): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - url.searchParams.set("include_secret_keys", "true"); + url.searchParams.set("include_secret_keys", String(options?.includeSecretKeys ?? true)); const response = await plapiFetch("GET", url); return response.json() as Promise; } @@ -331,3 +339,98 @@ export async function listApplications(): Promise { const response = await plapiFetch("GET", url); return response.json() as Promise; } + +export type NativeSettings = { object: "native_settings"; api_enabled: boolean }; +export type AndroidApplication = { + object: "android_application"; + id: string; + namespace: string; + package_name: string; + fingerprints: string[]; +}; + +function nativeUrl(appId: string, instanceId: string, path: string): URL { + return new URL( + `/v1/platform/applications/${encodeURIComponent(appId)}/instances/${encodeURIComponent(instanceId)}/${path}`, + getPlapiBaseUrl(), + ); +} + +async function readNativeSettings(response: Response): Promise { + const value = (await response.json()) as Partial | null; + if (!value || value.object !== "native_settings" || typeof value.api_enabled !== "boolean") { + throw new CliError( + "Unexpected Native API settings response. No further setup changes were made.", + ); + } + return { object: "native_settings", api_enabled: value.api_enabled }; +} + +function readAndroidApplication(value: unknown): AndroidApplication { + const app = value as Partial | null; + if ( + !app || + app.object !== "android_application" || + typeof app.id !== "string" || + !app.id || + typeof app.namespace !== "string" || + typeof app.package_name !== "string" || + !Array.isArray(app.fingerprints) || + !app.fingerprints.every((v) => typeof v === "string") + ) { + throw new CliError( + "Unexpected Android registration response. No further setup changes were made.", + ); + } + return { + object: "android_application", + id: app.id, + namespace: app.namespace, + package_name: app.package_name, + fingerprints: app.fingerprints, + }; +} + +export async function getNativeSettings( + appId: string, + instanceId: string, +): Promise { + return readNativeSettings( + await plapiFetch("GET", nativeUrl(appId, instanceId, "native_settings")), + ); +} + +export async function enableNativeApi(appId: string, instanceId: string): Promise { + return readNativeSettings( + await plapiFetch("PATCH", nativeUrl(appId, instanceId, "native_settings"), { + body: JSON.stringify({ api_enabled: true }), + }), + ); +} + +export async function listAndroidApplications( + appId: string, + instanceId: string, +): Promise { + const response = await plapiFetch( + "GET", + nativeUrl(appId, instanceId, "native_applications/android"), + ); + const apps: unknown = await response.json(); + if (!Array.isArray(apps)) throw new CliError("Unexpected Android registrations response."); + return apps.map(readAndroidApplication); +} + +export async function createAndroidApplication( + appId: string, + instanceId: string, + params: { namespace: "android_app"; package_name: string; fingerprints: string[] }, + idempotencyKey: string, +): Promise { + const response = await plapiFetch( + "POST", + nativeUrl(appId, instanceId, "native_applications/android"), + { body: JSON.stringify(params), idempotencyKey }, + ); + return readAndroidApplication(await response.json()); +} diff --git a/test/e2e/fixtures/android/README.md b/test/e2e/fixtures/android/README.md index cfd14f074..fa064a108 100644 --- a/test/e2e/fixtures/android/README.md +++ b/test/e2e/fixtures/android/README.md @@ -1,7 +1,10 @@ # Android e2e fixture -Bare Android project markers, hand-authored (not in `fixtures.manifest.ts` — -the refresh script never touches this directory). `clerk init` on Android -writes no project files: it detects the platform via `AndroidManifest.xml`, -pulls keys into `.env`, and prints the SDK quickstart steps. -`native-init.test.ts` asserts exactly that. +A minimal Android application module for CLI setup tests. It is hand-authored +and is not refreshed by `fixtures.manifest.ts`. + +`native-init.test.ts` verifies Native API registration, generated Gradle/Kotlin +setup, publishable-key resources, and idempotent reruns. The fixed package name +allows the dedicated development test instance to reuse one registration. +The fixture is not a standalone Android build; a build smoke test needs root +Gradle plugin/repository configuration and an Android SDK. diff --git a/test/e2e/fixtures/android/app/build.gradle.kts b/test/e2e/fixtures/android/app/build.gradle.kts new file mode 100644 index 000000000..1318d010a --- /dev/null +++ b/test/e2e/fixtures/android/app/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("com.android.application") +} + +android { + namespace = "com.clerk.cli.e2e" + compileSdk = 36 + defaultConfig { + applicationId = "com.clerk.cli.e2e" + minSdk = 24 + targetSdk = 36 + } +} diff --git a/test/e2e/fixtures/android/app/src/main/AndroidManifest.xml b/test/e2e/fixtures/android/app/src/main/AndroidManifest.xml index 4f4bf5322..c9bf6aa80 100644 --- a/test/e2e/fixtures/android/app/src/main/AndroidManifest.xml +++ b/test/e2e/fixtures/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - - + + + diff --git a/test/e2e/fixtures/android/build.gradle.kts b/test/e2e/fixtures/android/build.gradle.kts new file mode 100644 index 000000000..926dad5ab --- /dev/null +++ b/test/e2e/fixtures/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("com.android.application") version "9.3.1" apply false +} diff --git a/test/e2e/native-init.test.ts b/test/e2e/native-init.test.ts index 30bd28287..e53712867 100644 --- a/test/e2e/native-init.test.ts +++ b/test/e2e/native-init.test.ts @@ -9,15 +9,7 @@ import { log } from "./lib/logger.ts"; const FIXTURES_DIR = join(import.meta.dir, "fixtures"); const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); -/** - * Native platforms (iOS, Android) have no package.json, no npm install, and no - * build CI can run — Xcode and Gradle toolchains aren't available. So instead - * of the manifest/`createFixtureHarness` flow, this test asserts the whole of - * what `clerk init` promises on native: platform detection from marker files, - * keys pulled into `.env`, zero project files written, and the SDK quickstart - * printed. The fixtures are hand-authored marker stubs the refresh script - * never touches. - */ +/** iOS retains its instruction-only setup; Android setup is tested below. */ const PLATFORMS = [ { fixture: "ios", @@ -25,11 +17,6 @@ const PLATFORMS = [ // One stable phrase per printed quickstart step that would break setup if dropped. instructions: ["Swift Package Manager", "dashboard.clerk.com/~/native-applications"], }, - { - fixture: "android", - detectedName: "Android (Kotlin)", - instructions: ["app/build.gradle.kts", "dashboard.clerk.com/~/native-applications"], - }, ] as const; test.each([...PLATFORMS])( @@ -92,3 +79,57 @@ test.each([...PLATFORMS])( }, { timeout: 120_000 }, ); + +test( + "clerk init configures Android and safely reuses its registration", + async () => { + const platformAPIKey = process.env.CLERK_PLATFORM_API_KEY; + if (!platformAPIKey) throw new Error("Missing required env var: CLERK_PLATFORM_API_KEY"); + const projectDir = await mkdtemp(join(await realpath(tmpdir()), "clerk-e2e-android-")); + const configDir = await mkdtemp(join(await realpath(tmpdir()), "clerk-e2e-config-")); + try { + await cp(join(FIXTURES_DIR, "android"), projectDir, { recursive: true }); + await gitInit(projectDir); + await linkProject(projectDir, configDir); + const run = async () => + Bun.$`bun ${CLI_PATH} --mode human init --yes --no-skills` + .cwd(projectDir) + .env({ + CLERK_CONFIG_DIR: configDir, + CLERK_PLATFORM_API_KEY: platformAPIKey, + CLERK_TELEMETRY_DISABLED: "1", + }) + .quiet() + .nothrow(); + const first = await run(); + const output = first.stdout.toString() + first.stderr.toString(); + log(`Android init output:\n${output}`); + expect(first.exitCode).toBe(0); + expect(output).toContain("Android setup complete"); + expect(await Bun.file(join(projectDir, "app/build.gradle.kts")).text()).toContain( + "com.clerk:clerk-android-api:", + ); + expect( + await Bun.file( + join(projectDir, "app/src/main/kotlin/com/clerk/cli/e2e/ClerkApplication.kt"), + ).text(), + ).toContain("Clerk.initialize"); + const resource = await Bun.file(join(projectDir, "app/src/main/res/values/clerk.xml")).text(); + expect(resource).toContain("pk_test_"); + expect(resource).not.toContain("sk_"); + expect(await Bun.file(join(projectDir, ".env")).exists()).toBe(false); + const second = await run(); + expect(second.exitCode).toBe(0); + expect(second.stdout.toString() + second.stderr.toString()).toContain( + "Android is already set up", + ); + expect(await Bun.file(join(projectDir, "app/src/main/res/values/clerk.xml")).text()).toBe( + resource, + ); + } finally { + await rm(projectDir, { recursive: true, force: true }); + await rm(configDir, { recursive: true, force: true }); + } + }, + { timeout: 120_000 }, +);