From 0f803155d9f6fbd34fa6e2b2192b1de587ad6076 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 28 Oct 2026 12:23:17 -0700 Subject: [PATCH 1/3] feat(init): create a new Android app with clerk init --starter clerk init works in an empty folder for Android as it does for iOS and macOS: "Android (Jetpack Compose)" joins the picker everywhere, and --starter --framework android takes --name and --android-package (default com.example.); missing answers are StarterInputRequired, reported as input-required with --json, and no accountless mode applies. The starter is shaped like Android Studio's Empty Activity template: a version catalog pinning AGP 9.2.1, Kotlin 2.3.21 through the Compose compiler plugin alias (AGP 9's built-in Kotlin would be 2.2.10, which the SDK can't be consumed by), a Compose BOM, an app module with minSdk 24, the manifest, MainActivity, the theme, a vector launcher icon, .gitignore, an initial Git commit, and the Gradle 9.4.1 wrapper. The wrapper's scripts and jar are embedded in the CLI (Bun file imports) and copied in, with the distribution's checksum pinned in gradle-wrapper.properties, so the project builds with ./gradlew without Gradle on PATH. The existing Android setup then runs on the new app with no confirmation and the UI artifact by default. --prebuilt-auth-ui (or a yes to the prompt, on by default for a new starter) replaces the unchanged MainActivity with the prebuilt sign-in: progress while Clerk.isInitialized is false, AuthView until the auth flow completes, then UserButton and the signed-in email. Every JSON status after the app is written carries the starter and its continuation, and a setup error becomes setup-interrupted. StarterInputRequired, the starter summary, and setupInterrupted move to native/ so both engines share them. --- packages/cli-core/src/commands/init/README.md | 9 +- .../commands/init/android/coordinator.test.ts | 121 +++++ .../src/commands/init/android/coordinator.ts | 54 +- .../init/android/starter-bootstrap.test.ts | 117 ++++ .../init/android/starter-bootstrap.ts | 167 ++++++ .../init/android/starter-project.test.ts | 162 ++++++ .../commands/init/android/starter-project.ts | 511 ++++++++++++++++++ .../src/commands/init/android/starter.ts | 100 ++++ .../init/android/starter/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../src/commands/init/android/starter/gradlew | 251 +++++++++ .../commands/init/android/starter/gradlew.bat | 94 ++++ .../src/commands/init/android/workflow.ts | 40 +- .../src/commands/init/bootstrap.test.ts | 24 +- .../cli-core/src/commands/init/bootstrap.ts | 93 +++- .../src/commands/init/index-android.test.ts | 135 ++++- .../src/commands/init/index-ios.test.ts | 7 +- packages/cli-core/src/commands/init/index.ts | 115 +++- .../src/commands/init/ios/coordinator.ts | 40 +- .../commands/init/ios/starter-bootstrap.ts | 40 +- .../src/commands/init/native/starter-input.ts | 43 ++ .../src/commands/init/native/status.ts | 27 + packages/cli-core/src/globals.d.ts | 14 + 22 files changed, 2034 insertions(+), 130 deletions(-) create mode 100644 packages/cli-core/src/commands/init/android/starter-bootstrap.test.ts create mode 100644 packages/cli-core/src/commands/init/android/starter-bootstrap.ts create mode 100644 packages/cli-core/src/commands/init/android/starter-project.test.ts create mode 100644 packages/cli-core/src/commands/init/android/starter-project.ts create mode 100644 packages/cli-core/src/commands/init/android/starter.ts create mode 100644 packages/cli-core/src/commands/init/android/starter/gradle-wrapper.jar create mode 100755 packages/cli-core/src/commands/init/android/starter/gradlew create mode 100644 packages/cli-core/src/commands/init/android/starter/gradlew.bat create mode 100644 packages/cli-core/src/commands/init/native/starter-input.ts diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 74c3ef3ee..849699477 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -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.`); 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 && clerk init --app --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 --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 && clerk init --app --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: @@ -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.`), 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 && clerk init --app --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 `` 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. @@ -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) diff --git a/packages/cli-core/src/commands/init/android/coordinator.test.ts b/packages/cli-core/src/commands/init/android/coordinator.test.ts index 8a805db50..6c0a10e33 100644 --- a/packages/cli-core/src/commands/init/android/coordinator.test.ts +++ b/packages/cli-core/src/commands/init/android/coordinator.test.ts @@ -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"; @@ -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")], + }); + }); +}); diff --git a/packages/cli-core/src/commands/init/android/coordinator.ts b/packages/cli-core/src/commands/init/android/coordinator.ts index dbd78807c..af314adc3 100644 --- a/packages/cli-core/src/commands/init/android/coordinator.ts +++ b/packages/cli-core/src/commands/init/android/coordinator.ts @@ -9,6 +9,8 @@ import { stopNativeProgress, withNativeSpinner } from "../native/progress.ts"; import { SelectionNeeded } from "./discovery.ts"; import { describeIdentity, IdentityRequired } from "./identity.ts"; import { inspectAndroidProject } from "./project.ts"; +import { isUnchangedStarter } from "./starter.ts"; +import { setupInterrupted, type StarterProjectSummary } from "../native/status.ts"; import { ARTIFACTS, CLERK_ANDROID_MINIMUM_VERSION, sdkInstalled } from "./sdk.ts"; import { ANDROID_QUICKSTART, type AndroidSdk } from "./types.ts"; import { @@ -23,8 +25,14 @@ import { export { CLERK_ANDROID_MINIMUM_VERSION, ANDROID_QUICKSTART }; -export function setupNextSteps(result: ApplyResult): string[] { +export function setupNextSteps(result: ApplyResult, starter?: StarterProjectSummary): string[] { const initialized = result.handoff.completed.some((item) => item.id === "initialize-clerk"); + if (starter && initialized) + return [ + `Open ${starter.projectName} in Android Studio, or run \`./gradlew :app:assembleDebug\` there.`, + "Run the app on an emulator or device and test your sign-in flow.", + `Quickstart: ${ANDROID_QUICKSTART}`, + ]; return [ initialized ? "Sync Gradle, build your app, and connect your sign-in flow using the quickstart." @@ -71,22 +79,25 @@ export function printSetupPreview(preview: SetupPreview): void { if (preview.dirtyFiles.length) log.info(" Existing edits will be preserved in backups."); if (!preview.local.initializes) log.info(" Kotlin integration remains: initialize Clerk in the Application subclass."); + else if (!preview.starter?.tasks.length && preview.inspection.sdk === "ui") + log.info(" Kotlin integration remains: connect your sign-in flow using the quickstart."); } -export function printSetupResult(result: ApplyResult): void { +export function printSetupResult(result: ApplyResult, starter?: StarterProjectSummary): void { if (result.message) log.warn(result.message); const completed = result.handoff.completed; const summary = [ completed.some((item) => item.id === "sdk-dependency") && "SDK dependency declared", completed.some((item) => item.id === "initialize-clerk") && "Clerk initialized", result.remote === "verified" && "native registration verified", + completed.some((item) => item.id === "optional-sign-in-ui") && "sign-in screen added", ].filter(Boolean); if (summary.length) log.success(summary.join(" · ")); for (const item of result.handoff.remaining) log.warn(item.detail); if (result.recovery?.needsReview.length) log.warn(`Review interrupted edits: ${result.recovery.needsReview.join(", ")}`); log.info("\nNext steps:"); - for (const step of setupNextSteps(result)) log.info(` • ${step}`); + for (const step of setupNextSteps(result, starter)) log.info(` • ${step}`); } /** The Android-specific `clerk init` options, as Commander names them. */ @@ -100,6 +111,10 @@ export interface AndroidInitOptions { androidPackage?: string; androidFingerprint?: string[]; androidSdk?: AndroidSdk; + /** Add Clerk's prebuilt sign-in screen to an unchanged Compose starter. */ + prebuiltAuthUi?: boolean; + /** The app `clerk init --starter` just created, which needs no confirmation before setup. */ + starterProject?: StarterProjectSummary; } /** @@ -116,18 +131,21 @@ export async function runAndroidInit( // As elsewhere in init, agent mode implies --yes. const interactive = !options.agent && !options.yes; const signal = interruptSignal(); + const newApp = options.starterProject; let reported = false; const report = (status: object, indent?: number) => { reported = true; - log.data(JSON.stringify(status, null, indent)); + log.data(JSON.stringify({ ...status, ...(newApp && { starter: newApp }) }, null, indent)); }; const setup: SetupOptions = { root: options.root, module: options.androidModule, applicationId: options.androidPackage, - sdk: options.androidSdk ?? "core", + // A new starter has no screens of its own yet, so it gets the UI artifact by default. + sdk: options.androidSdk ?? (newApp ? "ui" : "core"), minimumVersion: CLERK_ANDROID_MINIMUM_VERSION, fingerprints: options.androidFingerprint, + signInUI: options.prebuiltAuthUi, inspectOnly: options.dryRun, signal, chooseModule: @@ -159,6 +177,7 @@ export async function runAndroidInit( ); stopNativeProgress(); setup.inspection = inspection; + const starter = await isUnchangedStarter(inspection); // An SDK the project already declares decides the products, as on iOS. if (!options.androidSdk) for (const sdk of ["ui", "core"] as const) @@ -166,9 +185,21 @@ export async function runAndroidInit( setup.sdk = sdk; break; } + // A new starter has no screens of its own yet, so its sign-in screen defaults to on: --yes + // takes that default as the prompt below offers it. An agent still needs the user to ask for + // UI changes (see the handoff). + if ( + newApp && + starter && + !interactive && + !options.agent && + options.prebuiltAuthUi == null && + setup.sdk === "ui" + ) + setup.signInUI = true; if (interactive) { log.success(`Found Android app: ${inspection.selection.applicationId}`); - if (!options.androidSdk && !inspection.dependencies.length) + if (!options.androidSdk && !inspection.dependencies.length && !starter) setup.sdk = await select({ message: "Which Clerk SDK artifact should this app use?", choices: [ @@ -176,6 +207,12 @@ export async function runAndroidInit( { name: `${ARTIFACTS.ui} (prebuilt authentication UI)`, value: "ui" as const }, ], }); + if (options.prebuiltAuthUi == null && starter && setup.sdk === "ui") + setup.signInUI = await confirm({ + message: "Add Clerk’s prebuilt sign-in screen?", + // A new starter has no screens of its own yet. + default: newApp != null, + }); } if (setup.sdk !== inspection.sdk) setup.inspection = { ...inspection, sdk: setup.sdk }; setup.remote = { applicationId }; @@ -185,13 +222,13 @@ export async function runAndroidInit( ); stopNativeProgress(); if (!json) printSetupPreview(preview); - if (interactive && !(await confirm({ message: "Apply this setup?", default: true }))) + if (interactive && !newApp && !(await confirm({ message: "Apply this setup?", default: true }))) throwUserAbort(); setTelemetryStage("android_apply"); const result = await applySetup(preview, dependencies, signal); stopNativeProgress(); if (json) report(result, 2); - else printSetupResult(result); + else printSetupResult(result, newApp); if (result.status === "incomplete") throw new CliError( result.message ?? @@ -224,6 +261,7 @@ export async function runAndroidInit( choices: error.choices, appIntegrationComplete: false, }); + else if (json && newApp && !reported) report(setupInterrupted(newApp, error), 2); throw error; } } diff --git a/packages/cli-core/src/commands/init/android/starter-bootstrap.test.ts b/packages/cli-core/src/commands/init/android/starter-bootstrap.test.ts new file mode 100644 index 000000000..6055587bf --- /dev/null +++ b/packages/cli-core/src/commands/init/android/starter-bootstrap.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ERROR_CODE } from "../../../lib/errors.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { StarterInputRequired } from "../native/starter-input.ts"; +import { + defaultApplicationId, + promptAndroidStarter, + type AndroidStarterOptions, +} from "./starter-bootstrap.ts"; + +useCaptureLog(); +let tmp: string; +const spies: { mockRestore(): void }[] = []; +const run = (options: Partial) => + promptAndroidStarter(tmp, { skipConfirm: true, ...options }); + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "clerk-android-starter-")); +}); +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("promptAndroidStarter", () => { + test("writes the project with the flags' answers", async () => { + const result = await run({ name: "MyApp", applicationId: "com.acme.myapp" }); + expect(result).toEqual({ + projectDir: join(tmp, "MyApp"), + projectName: "MyApp", + android: { applicationId: "com.acme.myapp" }, + }); + expect(await Bun.file(join(tmp, "MyApp/app/build.gradle.kts")).text()).toContain( + 'applicationId = "com.acme.myapp"', + ); + }); + + test("defaults the name and Android Studio's applicationId when it isn't asked", async () => { + const result = await run({}); + expect(result.projectName).toBe("MyClerkApp"); + expect(result.android.applicationId).toBe("com.example.myclerkapp"); + expect(defaultApplicationId("My_App")).toBe("com.example.my_app"); + }); + + test("skips a default name that's taken", async () => { + mkdirSync(join(tmp, "MyClerkApp")); + expect((await run({})).projectName).toBe("MyClerkApp2"); + }); + + test("asks at a terminal, with the defaults offered", async () => { + const text = spyOn(prompts, "text") + .mockResolvedValueOnce("Demo") + .mockResolvedValueOnce("org.example.demo"); + spies.push(text); + const result = await run({ skipConfirm: false }); + expect(text.mock.calls.map((call) => call[0].default)).toEqual([ + "MyClerkApp", + "com.example.demo", + ]); + expect(result).toMatchObject({ + projectName: "Demo", + android: { applicationId: "org.example.demo" }, + }); + }); + + test.each([ + [{ name: "2fast" }, "name", "start with a letter"], + [{ name: "class" }, "name", "Kotlin keyword"], + [{ name: "MyApp", applicationId: "myapp" }, "androidPackage", "reverse-DNS"], + [{ name: "MyApp", applicationId: "com.example.object" }, "androidPackage", "Kotlin keyword"], + ])("refuses %j before writing anything", async (options, field, message) => { + const error = await run(options).catch((e: unknown) => e); + expect(error).toBeInstanceOf(StarterInputRequired); + expect((error as StarterInputRequired).describe()).toMatchObject({ + field, + issue: expect.stringContaining(message), + }); + expect((error as StarterInputRequired).describe().flag).toBe( + field === "name" ? "--name" : "--android-package", + ); + expect(await Bun.file(join(tmp, "MyApp/settings.gradle.kts")).exists()).toBe(false); + }); + + test("with --json, a missing --name is asked for with a suggestion", async () => { + const error = await run({ requireName: true }).catch((e: unknown) => e); + expect((error as StarterInputRequired).describe()).toMatchObject({ + field: "name", + suggestion: "MyClerkApp", + }); + }); + + test("refuses a name whose directory exists", async () => { + mkdirSync(join(tmp, "Taken")); + const error = await run({ name: "Taken" }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(StarterInputRequired); + expect((error as StarterInputRequired).code).toBe(ERROR_CODE.PROJECT_DIR_EXISTS); + }); + + test("runs beforeWrite once the answers are settled, and writes nothing when it throws", async () => { + const seen: string[] = []; + await expect( + run({ + name: "MyApp", + beforeWrite: async (dir) => { + seen.push(dir); + throw new Error("linked elsewhere"); + }, + }), + ).rejects.toThrow("linked elsewhere"); + expect(seen).toEqual([join(tmp, "MyApp")]); + expect(await Bun.file(join(tmp, "MyApp/settings.gradle.kts")).exists()).toBe(false); + }); +}); diff --git a/packages/cli-core/src/commands/init/android/starter-bootstrap.ts b/packages/cli-core/src/commands/init/android/starter-bootstrap.ts new file mode 100644 index 000000000..1d9c54140 --- /dev/null +++ b/packages/cli-core/src/commands/init/android/starter-bootstrap.ts @@ -0,0 +1,167 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { text } from "../../../lib/prompts.ts"; +import { log } from "../../../lib/log.ts"; +import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; +import { dirExists } from "../context.ts"; +import { StarterInputRequired } from "../native/starter-input.ts"; +import { PACKAGE_NAME } from "./gradle.ts"; +import { ANDROID_APP_NAME, writeAndroidStarter } from "./starter-project.ts"; + +export const ANDROID_STARTER = { label: "Android (Jetpack Compose)", value: "android" } as const; + +/** What the new Gradle project was created with, for the native setup that follows. */ +export interface AndroidStarter { + applicationId: string; +} + +export interface AndroidStarterOptions { + /** Non-interactive: take every answer from flags or defaults. */ + skipConfirm: boolean; + /** `--json`: the user names the app, so a missing `--name` is asked for instead of defaulted. */ + requireName?: boolean; + name?: string; + /** `--android-package`: the applicationId, which is also the namespace and Kotlin package. */ + applicationId?: string; + /** Runs once every answer is settled, right before the app is written; a throw writes nothing. */ + beforeWrite?: (projectDir: string) => Promise; +} + +const DEFAULT_NAME = "MyClerkApp"; +// Kotlin hard keywords can't be package segments; the lowercased name is the default's last one. +const KOTLIN_KEYWORDS = new Set([ + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", +]); + +function nameProblem(name: string): string | undefined { + if (!ANDROID_APP_NAME.test(name)) + return "App name must start with a letter and use only letters, numbers, and underscores, like MyApp."; + if (KOTLIN_KEYWORDS.has(name.toLowerCase())) + return `${name} is a Kotlin keyword, so it can't name the app's package. Pick a different name, like MyApp.`; + return undefined; +} + +async function availableName(cwd: string): Promise { + if (!(await dirExists(join(cwd, DEFAULT_NAME)))) return DEFAULT_NAME; + for (let i = 2; i < 1000; i++) { + const candidate = `${DEFAULT_NAME}${i}`; + if (!(await dirExists(join(cwd, candidate)))) return candidate; + } + throw new CliError(`Could not find an available app name based on '${DEFAULT_NAME}'.`, { + code: ERROR_CODE.PROJECT_DIR_EXISTS, + }); +} + +async function chooseName(cwd: string, options: AndroidStarterOptions): Promise { + if (options.name) { + const problem = nameProblem(options.name); + if (problem) + throw new StarterInputRequired(`Invalid --name "${options.name}": ${problem}`, "name"); + return options.name; + } + const suggested = await availableName(cwd); + if (options.requireName) + throw new StarterInputRequired("--name is required for a new app with --json.", "name", { + suggestion: suggested, + }); + if (options.skipConfirm) return suggested; + const name = await text({ + message: "App name:", + default: suggested, + validate: (value) => { + const trimmed = value?.trim() ?? ""; + return ( + nameProblem(trimmed) ?? + (existsSync(join(cwd, trimmed)) + ? `Directory '${trimmed}' already exists. Pick a different name.` + : undefined) + ); + }, + }); + return name.trim(); +} + +function packageProblem(value: string): string | undefined { + if (!PACKAGE_NAME.test(value)) + return "Enter a reverse-DNS applicationId with at least two segments, like com.example.myapp."; + if (value.split(".").some((segment) => KOTLIN_KEYWORDS.has(segment))) + return "An applicationId segment can't be a Kotlin keyword, since it's also the Kotlin package."; + return undefined; +} + +/** Android Studio's default: `com.example.` plus the lowercased app name. */ +export const defaultApplicationId = (name: string) => `com.example.${name.toLowerCase()}`; + +async function chooseApplicationId(name: string, options: AndroidStarterOptions): Promise { + if (options.applicationId) { + const problem = packageProblem(options.applicationId); + if (problem) + throw new StarterInputRequired( + `Invalid --android-package "${options.applicationId}": ${problem}`, + "androidPackage", + ); + return options.applicationId; + } + const suggested = defaultApplicationId(name); + if (options.skipConfirm) return suggested; + const value = await text({ + message: "Application ID (package name):", + default: suggested, + validate: (input) => packageProblem(input?.trim() ?? ""), + }); + return value.trim(); +} + +/** + * Asks for the new app's name and applicationId, then writes a Jetpack Compose starter that native + * setup recognizes. Every answer is settled before anything is written. No toolchain is needed to + * write it; the Android SDK and a JDK are needed to build it. + */ +export async function promptAndroidStarter( + cwd: string, + options: AndroidStarterOptions, +): Promise<{ projectDir: string; projectName: string; android: AndroidStarter }> { + const name = await chooseName(cwd, options); + const projectDir = join(cwd, name); + if (await dirExists(projectDir)) + throw new StarterInputRequired( + `Directory '${name}' already exists. Pick a different name or remove it first.`, + "name", + {}, + ERROR_CODE.PROJECT_DIR_EXISTS, + ); + const applicationId = await chooseApplicationId(name, options); + await options.beforeWrite?.(projectDir); + + log.blank(); + log.info(`Creating \`${name}\` Android app...`); + await writeAndroidStarter(projectDir, { name, applicationId }); + return { projectDir, projectName: name, android: { applicationId } }; +} diff --git a/packages/cli-core/src/commands/init/android/starter-project.test.ts b/packages/cli-core/src/commands/init/android/starter-project.test.ts new file mode 100644 index 000000000..286305c81 --- /dev/null +++ b/packages/cli-core/src/commands/init/android/starter-project.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { readdir, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { CLERK_ANDROID_MINIMUM_VERSION } from "./sdk.ts"; +import { inspectAndroidProject } from "./project.ts"; +import { planAndroidSetup } from "./plan.ts"; +import { + ANDROID_APP_NAME, + STARTER_VERSIONS, + signInMainActivity, + starterMainActivity, + starterProjectFiles, + writeAndroidStarter, +} from "./starter-project.ts"; +import { isUnchangedStarter, planStarter } from "./starter.ts"; + +const project = { name: "MyApp", applicationId: "com.example.myapp" }; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("starterProjectFiles", () => { + const files = starterProjectFiles(project); + + test("pins a toolchain the Clerk SDK can be consumed by", () => { + const catalog = files["gradle/libs.versions.toml"]!; + expect(catalog).toContain(`agp = "${STARTER_VERSIONS.agp}"`); + expect(catalog).toContain(`kotlin = "${STARTER_VERSIONS.kotlin}"`); + expect(catalog).toContain('kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose"'); + expect(files["app/build.gradle.kts"]).toContain("minSdk = 24"); + expect(files["app/build.gradle.kts"]).toContain("alias(libs.plugins.kotlin.compose)"); + expect(files["gradle/wrapper/gradle-wrapper.properties"]).toContain( + `gradle-${STARTER_VERSIONS.gradle}-bin.zip`, + ); + expect(files["gradle/wrapper/gradle-wrapper.properties"]).toContain( + `distributionSha256Sum=${STARTER_VERSIONS.gradleSha256}`, + ); + }); + + test("names the app, package, and theme consistently", () => { + expect(files["settings.gradle.kts"]).toContain('rootProject.name = "MyApp"'); + expect(files["app/build.gradle.kts"]).toContain('namespace = "com.example.myapp"'); + expect(files["app/build.gradle.kts"]).toContain('applicationId = "com.example.myapp"'); + expect(files["app/src/main/AndroidManifest.xml"]).toContain( + 'android:theme="@style/Theme.MyApp"', + ); + expect(files["app/src/main/res/values/themes.xml"]).toContain('