Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9144472
docs: design for Android onboarding app-existence check via Play Deve…
WcaleNieWolny Jun 5, 2026
161ecea
docs: Android verify spec — Trapeze auto-rename (Path A) + Play Conso…
WcaleNieWolny Jun 5, 2026
0205945
docs: correct overstated apps:search claim — androidpublisher first-u…
WcaleNieWolny Jun 5, 2026
98699fe
docs: confirm no programmatic create/first-upload for public Play app…
WcaleNieWolny Jun 5, 2026
db0593c
docs: correct Path B — app-record creation is the only UI-only step (…
WcaleNieWolny Jun 5, 2026
7b34b38
docs: resolve draft-upload question — builder already defaults releas…
WcaleNieWolny Jun 5, 2026
29a3384
docs: consolidate Android verify spec into clean v1 (resolved facts +…
WcaleNieWolny Jun 5, 2026
0bf7480
docs: clarify Path A rename is explicit opt-in, never automatic (no s…
WcaleNieWolny Jun 6, 2026
6f95e78
docs: record Android verify decisions + add phased implementation plan
WcaleNieWolny Jun 6, 2026
f95aad8
feat(cli): Android app-verify pure modules - reporting apps:search + …
WcaleNieWolny Jun 6, 2026
b38162f
feat(cli): verify Android app vs Play apps on package-select (scope +…
WcaleNieWolny Jun 6, 2026
2266b36
feat(cli): Path A Trapeze rename + Path B create-app + telemetry for …
WcaleNieWolny Jun 6, 2026
95887b9
fix(cli): address Android app-verify review findings
WcaleNieWolny Jun 6, 2026
32ce60a
feat(cli): explain the Play app-list scope on the pre-OAuth screens
WcaleNieWolny Jun 6, 2026
640c902
feat(cli): warn on the OAuth success page when the optional Play app-…
WcaleNieWolny Jun 6, 2026
0a875c6
fix(cli): drop useless escape in success-page template (oxlint)
WcaleNieWolny Jun 6, 2026
1d8c091
fix(cli): make Android app-verify a hard gate — never advance with a …
WcaleNieWolny Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@
"test:app-verification": "bun test/test-app-verification.mjs",
"test:pbxproj-parser": "bun test/test-pbxproj-parser.mjs",
"test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit",
"test:android-reporting-api": "bun test/test-android-reporting-api.mjs",
"test:android-app-verification": "bun test/test-android-app-verification.mjs",
"test:android-rename": "bun test/test-android-rename.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
"test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs",
Expand Down
102 changes: 102 additions & 0 deletions cli/src/build/onboarding/android/android-rename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// src/build/onboarding/android/android-rename.ts
//
// Pure helpers for the Path A "Rename my Android project for me" convenience
// (explicit opt-in only). All I/O — mkdtemp, npm install, spawning node /
// `cap sync`, and `pgrep` — lives in the app.tsx orchestration; everything
// here is pure so it can be unit-tested without touching the filesystem or
// spawning processes.

/**
* Pinned `@trapezedev/project` version installed on demand into the temp rename
* workspace. Trapeze is NOT bundled with the CLI (keeps it lean) — it's only
* installed when the user explicitly opts into the rename. Pinned so the rename
* behavior is reproducible.
*/
export const TRAPEZE_PROJECT_VERSION = '7.1.4'

export interface RenameWorkspaceFiles {
/** Contents of the temp `package.json` (`type: module` + pinned Trapeze). */
packageJson: string
/** Contents of the `rename.mjs` script run as `node rename.mjs <appId>`. */
renameMjs: string
}

/**
* Build the two files written into the temp rename workspace.
*
* The `rename.mjs` script runs the proven 3-call Trapeze sequence — ALWAYS all
* three setters (`setPackageName` + `setApplicationId` + `setNamespace`).
* Skipping `namespace` is not an option: AGP 8 requires it, and a package move
* with a stale namespace breaks `R`/`BuildConfig` imports and fails the build.
* The `appId` is read from `process.argv[2]` so the caller passes the target
* package as a CLI argument rather than templating it into the script.
*
* `pkg` is accepted for symmetry / future validation but is intentionally NOT
* interpolated into the script — the appId always flows in via argv at runtime,
* which avoids any string-injection of an attacker-controlled package into the
* generated JS.
*/
export function buildRenameWorkspaceFiles(pkg: string): RenameWorkspaceFiles {
void pkg
const packageJson = `${JSON.stringify(
{
name: 'capgo-android-rename',
private: true,
type: 'module',
devDependencies: {
'@trapezedev/project': TRAPEZE_PROJECT_VERSION,
},
},
null,
2,
)}\n`

const renameMjs = `import { MobileProject } from '@trapezedev/project'

const appId = process.argv[2]
if (!appId) {
console.error('Usage: node rename.mjs <appId>')
process.exit(1)
}

const project = new MobileProject('.', { android: { path: 'android' } })
await project.load()
await project.android?.setPackageName(appId)
const gradle = await project.android?.getGradleFile('app/build.gradle')
await gradle?.setApplicationId(appId)
await gradle?.setNamespace(appId)
await project.commit()
console.log(\`Renamed Android project to \${appId}\`)
`

return { packageJson, renameMjs }
}

/** Whether Android Studio is holding the project's native files open. */
export type AndroidStudioState = 'running' | 'not-running' | 'unknown'

/**
* Pure predicate for the close-Android-Studio gate. Editing native files while
* Studio holds them open risks a half-written project / Studio clobbering the
* change.
*
* Only macOS can be determined here: `pgrep -f "Android Studio"` is the source
* of `pgrepOutput`, so a non-empty (trimmed) result means it's running and an
* empty one means it's closed. On any other platform we can't reliably probe,
* so the caller falls back to a one-time confirm → `unknown`.
*/
export function isAndroidStudioRunning(platform: string, pgrepOutput: string): AndroidStudioState {
if (platform !== 'darwin')
return 'unknown'
return pgrepOutput.trim().length > 0 ? 'running' : 'not-running'
}

/**
* Verify the rename landed: re-read Gradle ids (via `findAndroidApplicationIds`,
* which returns `string[]`) and confirm the list now contains the target
* package. Returns `false` so the caller surfaces output + a manual fallback
* instead of claiming a false success.
*/
export function verifyRenamed(gradleIds: string[], target: string): boolean {
return gradleIds.includes(target)
}
95 changes: 95 additions & 0 deletions cli/src/build/onboarding/android/app-verification-android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// src/build/onboarding/android/app-verification-android.ts
//
// Pure decision logic for the Android "verify Play Store app" onboarding step.
//
// A thin wrapper around the shared iOS `classifyAppVerification` classifier so
// the exact-match / wrong-build-id / no-app decision stays in one place across
// platforms. The Android-specific wrinkle is *multiple* Gradle applicationIds
// (build flavors): when more than one id is present without a single clean
// match we force the picker (`multi-gradle`) rather than guessing.

import type { AscAppLike } from '../app-verification.js'
import { classifyAppVerification } from '../app-verification.js'

/** Minimal shape of a Play Store app needed for reconciliation. */
export interface PlayAppLike {
packageName: string
displayName: string
}

/**
* Reconcile outcome for the Android package-select step.
*
* - `exact-match` — a Play app's `packageName` == a single clean Gradle id.
* Auto-confirm, no picker. Carries the matched package.
* - `wrong-build-id` — apps exist but the build id matches none → Path A picker.
* - `no-app` — no Play apps at all → Path B (create the app).
* - `multi-gradle` — several Gradle flavors and no clean single match → force
* the picker so the user disambiguates.
*/
export type AndroidReconcileResult
= | { kind: 'exact-match', packageName: string }
| { kind: 'wrong-build-id' }
| { kind: 'no-app' }
| { kind: 'multi-gradle' }

export interface ReconcileAndroidAppInput {
/** Every distinct `applicationId` found in the project's Gradle files (≥0). */
gradleIds: string[]
/** Apps that actually exist in the user's Play Console. */
apps: PlayAppLike[]
}

/**
* Pure reconciliation of the Android app-existence invariant.
*
* The common Capacitor case is a single Gradle id, which defers entirely to the
* shared iOS classifier (`packageName` ↦ `bundleId`). Android has no
* Developer-portal registration split, so both iOS `no-app-*` results collapse
* to a single `no-app` route.
*
* Multiple Gradle ids are handled here: exactly one matching a Play app is
* still a clean single match (`exact-match`); zero apps is `no-app`; anything
* else forces the picker (`multi-gradle`).
*/
export function reconcileAndroidApp(input: ReconcileAndroidAppInput): AndroidReconcileResult {
const { gradleIds, apps } = input

// Map Play apps onto the shared classifier's shape so the decision stays
// shared with iOS.
const ascApps: AscAppLike[] = apps.map(a => ({ bundleId: a.packageName, name: a.displayName }))

// Single Gradle id (or none): defer to the shared iOS classifier.
if (gradleIds.length <= 1) {
const releaseBundleId = gradleIds[0] ?? ''
// Defensive: with no usable Gradle id there is nothing to match — never
// let an empty string "exact-match" (e.g. a malformed Play row that lost
// its packageName). Apps exist → the enriched picker (wrong-build-id);
// none → Path B (no-app).
if (!releaseBundleId)
return apps.length > 0 ? { kind: 'wrong-build-id' } : { kind: 'no-app' }
const { result } = classifyAppVerification({
releaseBundleId,
apps: ascApps,
registeredBundleIds: [],
})
if (result === 'exact-match')
return { kind: 'exact-match', packageName: releaseBundleId }
if (result === 'wrong-build-id')
return { kind: 'wrong-build-id' }
// no-app-identifier-exists / no-app-unregistered → Android has no portal
// registration split, so both collapse to a single no-app route.
return { kind: 'no-app' }
}

// Multiple Gradle flavors. Exactly one matching a Play app is still a clean
// single match → auto-confirm; otherwise force the picker.
const matchedIds = gradleIds.filter(id => ascApps.some(a => a.bundleId === id))
if (matchedIds.length === 1)
return { kind: 'exact-match', packageName: matchedIds[0] }

if (apps.length === 0)
return { kind: 'no-app' }

return { kind: 'multi-gradle' }
}
72 changes: 65 additions & 7 deletions cli/src/build/onboarding/android/oauth-google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export interface GoogleOAuthConfig {
*/
clientSecret?: string
scopes: readonly string[]
/**
* Subset of `scopes` whose absence must FAIL sign-in. Any scope present in
* `scopes` but NOT listed here is *optional*: the user may deselect it on the
* consent screen and sign-in still succeeds (the feature relying on it is
* expected to degrade gracefully). Defaults to all of `scopes` when omitted,
* preserving the original "every requested scope is required" behavior.
*/
requiredScopes?: readonly string[]
/** Extra params to include on the auth URL (e.g. `login_hint`, `prompt`). */
extraAuthParams?: Record<string, string>
}
Expand Down Expand Up @@ -273,11 +281,26 @@ function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[c] as string))
}

function successHtml(): string {
/**
* Human blurbs for OPTIONAL scopes a user may decline on the consent screen —
* shown on the success page so they understand the implication right where the
* decision just happened (the consent wording, e.g. "see metrics and data", is
* Google's and reads scarier / vaguer than what the CLI actually does).
*/
const OPTIONAL_SCOPE_BLURBS: Record<string, string> = {
'https://www.googleapis.com/auth/playdeveloperreporting':
'Play app list (Google words it "see metrics and data"): without it the CLI can\'t check that the app you\'re building exists in your Play Console, so it will trust the package from your Gradle config as-is.',
}

function successHtml(skippedOptionalScopes: readonly string[] = []): string {
const warning = skippedOptionalScopes.length > 0
? `<div class="warn"><strong>Heads-up:</strong> you skipped ${skippedOptionalScopes.length === 1 ? 'an optional permission' : 'some optional permissions'} — that's fine, onboarding continues without it.<ul>${skippedOptionalScopes.map(s => `<li>${escapeHtml(OPTIONAL_SCOPE_BLURBS[s] ?? s)}</li>`).join('')}</ul>Want it after all? Re-run onboarding and leave it checked on the consent screen.</div>`
: ''
return `<!doctype html><html><head><meta charset="utf-8"><title>Capgo — signed in</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:480px;margin:80px auto;padding:0 20px;color:#222}h1{font-size:22px}p{color:#555;line-height:1.5}</style>
<style>body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:480px;margin:80px auto;padding:0 20px;color:#222}h1{font-size:22px}p{color:#555;line-height:1.5}.warn{background:#fff7e6;border:1px solid #f0d59c;border-radius:6px;padding:12px 16px;color:#5c4a1a;font-size:14px;line-height:1.5}.warn ul{margin:8px 0;padding-left:20px}</style>
</head><body><h1>✅ You can close this tab</h1>
<p>Capgo CLI received your Google sign-in. Head back to your terminal to continue.</p>
${warning}
</body></html>`
}

Expand Down Expand Up @@ -332,6 +355,30 @@ export function findMissingScopes(grantedScope: string, requestedScopes: readonl
return requestedScopes.filter(s => !granted.has(s))
}

export interface MissingScopeSplit {
/** Required scopes the user didn't grant — sign-in must fail with a retry. */
missingRequired: string[]
/** Optional scopes the user declined — proceed, but tell them what they lose. */
skippedOptional: string[]
}

/**
* Split the requested-but-not-granted scopes into the required ones (block
* sign-in → `MissingScopesError`) and the optional ones (sign-in proceeds; the
* success page + caller warn about the degraded behavior).
*/
export function splitMissingScopes(
grantedScope: string,
scopes: readonly string[],
requiredScopes: readonly string[],
): MissingScopeSplit {
const missingAll = findMissingScopes(grantedScope, scopes)
return {
missingRequired: missingAll.filter(s => requiredScopes.includes(s)),
skippedOptional: missingAll.filter(s => !requiredScopes.includes(s)),
}
}

export interface LoopbackCallbackResult {
/** Authorization code Google returned in the query string. */
code: string
Expand Down Expand Up @@ -562,13 +609,24 @@ export async function runOAuthFlow(
// the user gets a clear "please grant all permissions" message in BOTH
// the browser tab and the CLI, instead of failing several API calls
// later with confusing 403s.
const missing = findMissingScopes(tokens.scope, config.scopes)
if (missing.length > 0) {
finishResponse(scopeMissingHtml(missing), 400)
throw new MissingScopesError(missing, tokens.scope)
//
// Only the REQUIRED scopes block sign-in. Optional scopes (in `scopes` but
// not in `requiredScopes`) may be declined — the caller is responsible for
// degrading gracefully when the granted token lacks them. Defaults to all
// requested scopes when `requiredScopes` is omitted.
const requiredScopes = config.requiredScopes ?? config.scopes
const { missingRequired, skippedOptional } = splitMissingScopes(tokens.scope, config.scopes, requiredScopes)
if (missingRequired.length > 0) {
finishResponse(scopeMissingHtml(missingRequired), 400)
throw new MissingScopesError(missingRequired, tokens.scope)
}

finishResponse(successHtml())
// Optional scopes declined → still a success, but say so in BOTH places:
// the browser tab (where the decline just happened) and the CLI status
// stream — so the later "verification skipped" warning isn't a surprise.
if (skippedOptional.length > 0)
options.onStatus?.('Note: you skipped the optional Play app-list permission — app-existence verification will be skipped.')
finishResponse(successHtml(skippedOptional))
return tokens
}
finally {
Expand Down
Loading
Loading