diff --git a/bun.lock b/bun.lock index 82c450f8f0..6e30831dad 100644 --- a/bun.lock +++ b/bun.lock @@ -207,7 +207,7 @@ }, "cli": { "name": "@capgo/cli", - "version": "8.1.9", + "version": "8.3.0", "bin": { "capgo": "dist/index.js", }, diff --git a/cli/build.mjs b/cli/build.mjs index 1dc9a08415..9bf0a2aa19 100644 --- a/cli/build.mjs +++ b/cli/build.mjs @@ -318,6 +318,8 @@ const buildCLI = Bun.build({ external: HELPER_PACKAGES, define: { 'process.env.SUPA_DB': '"production"', + 'globalThis.__CAPGO_DEV__': 'false', + 'globalThis.__CAPGO_MCP_ONBOARDING__': 'false', // Gates the CAPGO_KEYCHAIN_HELPER_PATH dev override. `false` here makes // the minifier delete the whole branch from release bundles — // publish_cli.yml asserts the string is absent from dist/index.js. @@ -351,6 +353,8 @@ const buildSDK = Bun.build({ external: HELPER_PACKAGES, define: { 'process.env.SUPA_DB': '"production"', + 'globalThis.__CAPGO_DEV__': 'false', + 'globalThis.__CAPGO_MCP_ONBOARDING__': 'false', // Gates the CAPGO_KEYCHAIN_HELPER_PATH dev override. `false` here makes // the minifier delete the whole branch from release bundles — // publish_cli.yml asserts the string is absent from dist/index.js. diff --git a/cli/package.json b/cli/package.json index 918cfb23f4..65a8ec2a9a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -109,12 +109,36 @@ "test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs", "test:bundle-id-detector": "bun test/test-bundle-id-detector.mjs", "test:apple-api-app-list": "bun test/test-apple-api-app-list.mjs", + "test:apple-api-cert-create": "bun test/test-apple-api-cert-create.mjs", "test:app-verification": "bun test/test-app-verification.mjs", "test:pbxproj-parser": "bun test/test-pbxproj-parser.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", "test:self-update": "bun test/test-self-update.mjs", "test:update-prompt": "bun test/test-update-prompt.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt", + "test:android-tail-engine": "bun test/test-android-tail-engine.mjs", + "test:android-tail-render": "bun test/test-android-tail-render.mjs", + "test:android-tail-routing": "bun test/test-android-tail-routing.mjs", + "test:dev-gate-stripped": "bun test/test-dev-gate-stripped.mjs", + "test:frame-fit-ios-shared": "bun test/test-frame-fit-ios-shared.mjs", + "test:ios-confirm-app-id": "bun test/test-ios-confirm-app-id.mjs", + "test:ios-create-new": "bun test/test-ios-create-new.mjs", + "test:ios-e2e": "bun test/test-ios-e2e.mjs", + "test:ios-flow-contract": "bun test/test-ios-flow-contract.mjs", + "test:ios-import-discovery": "bun test/test-ios-import-discovery.mjs", + "test:ios-import-export": "bun test/test-ios-import-export.mjs", + "test:ios-import-pickers": "bun test/test-ios-import-pickers.mjs", + "test:ios-import-recovery": "bun test/test-ios-import-recovery.mjs", + "test:ios-recovery": "bun test/test-ios-recovery.mjs", + "test:ios-resume": "bun test/test-ios-resume.mjs", + "test:ios-tail-handoff": "bun test/test-ios-tail-handoff.mjs", + "test:ios-tui-render": "bun test/test-ios-tui-render.mjs", + "test:p8-error": "bun test/test-p8-error.mjs", + "test:ios-tui-routing": "bun test/test-ios-tui-routing.mjs", + "test:ios-updater-sync-validation": "bun test/test-ios-updater-sync-validation.mjs", + "test:ios-verify-app": "bun test/test-ios-verify-app.mjs", + "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", + "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:platform-flow-contract && bun run test:tail-engine-shared", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", diff --git a/cli/src/__dev__/README.md b/cli/src/__dev__/README.md new file mode 100644 index 0000000000..0574c3a1fd --- /dev/null +++ b/cli/src/__dev__/README.md @@ -0,0 +1,16 @@ +# `src/__dev__/` — dev/test-only modules (NEVER shipped) + +Modules here exist only for development and testing (e.g. spoofing the Apple API +in AI tests). They MUST be referenced **only** from inside +`if (globalThis.__CAPGO_DEV__) { … }` branches. + +Why this is safe in the NPM release: + +1. The release build defines `globalThis.__CAPGO_DEV__ = false` (see `build.mjs`), + so `minify`'s dead-code elimination removes the branch **and** tree-shakes the + imported `__dev__` module out of `dist/index.js`. +2. npm publishes only `dist/` (`package.json` `files`), so source is never published. +3. `test/test-dev-gate-stripped.mjs` greps the built bundle for dev markers and + fails the build if any leak. + +Never import a `__dev__` module from a non-dev (always-on) code path. diff --git a/cli/src/build/mobileprovision-parser.ts b/cli/src/build/mobileprovision-parser.ts index 0183940470..e2ac12ebf0 100644 --- a/cli/src/build/mobileprovision-parser.ts +++ b/cli/src/build/mobileprovision-parser.ts @@ -77,7 +77,14 @@ function parseMobileprovisionBuffer(data: Buffer, source: string): Mobileprovisi return { name, uuid, applicationIdentifier, bundleId } } -function parseMobileprovisionBufferDetailed(data: Buffer, source: string): MobileprovisionDetail { +/** + * Buffer-based variant of {@link parseMobileprovisionDetailed} — parses the raw + * .mobileprovision bytes directly instead of reading a path. Used by the iOS + * onboarding engine's import-provide-profile-path effect, which reads the file + * via its injected `readFile` dep (so the IO stays at the boundary) and parses + * the returned Buffer here. `source` is a label used only in error messages. + */ +export function parseMobileprovisionBufferDetailed(data: Buffer, source = ''): MobileprovisionDetail { const base = parseMobileprovisionBuffer(data, source) const xmlStartMarker = ' + /** CI-secret destinations discovered at detecting-ci-secrets. */ + ciSecretTargets?: CiSecretTarget[] + /** Per-destination setup advice surfaced when no target is reachable. */ + ciSecretSetupAdvice?: CiSecretSetupAdvice[] + /** Resolved owner/repo (GitHub) the CLI will push secrets to. */ + ciSecretRepoLabel?: string | null + /** Which secret keys already exist on the remote (checking-ci-secrets). */ + ciSecretExistingKeys?: string[] + /** Human summary of the upload (uploading-ci-secrets). */ + ciSecretUploadSummary?: string + /** Absolute path of the written .env file (exporting-env). */ + envExportPath?: string + /** Set when env-export found nothing to write or threw — routed to build-complete, never thrown (exporting-env / overwrite-and-export-env). */ + envExportError?: string + /** Absolute path of the written workflow file (writing-workflow-file). */ + workflowFilePath?: string + /** The queued build URL (requesting-build). */ + buildUrl?: string + /** Streamed build-request log lines (requesting-build). */ + buildOutput?: string[] + /** Captured AI-analysis job id surfaced on a failed build (requesting-build). */ + aiJobId?: string + + // ── Workflow-builder sub-flow ctx (pick-package-manager / pick-build-script) ── + // Optional runtime data the workflow-builder views surface. Every field is + // OPTIONAL so a driver that only passes { appId } still gets a usable view + // (the static option tables defined below already cover the menus). + /** Detected package manager from the project's lockfile (pick-package-manager). */ + detectedPackageManager?: string + /** All scripts from package.json (pick-build-script picker). */ + availableScripts?: Record + /** Project-type recommendation surfaced at the top of pick-build-script. */ + recommendedScript?: string | null + /** Default `.env` export path shown at ask-export-env (defaultExportPath). */ + defaultEnvExportPath?: string +} + +// ─── 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', + // TUI-only states (main's ink TUI; the MCP engine never returns these) — present + // only to satisfy the exhaustive Record. + 'resume-prompt': 'auto', + 'ai-analysis-prompt': 'auto', + 'ai-analysis-running': 'auto', + 'ai-analysis-result': 'auto', + 'ai-analysis-result-scroll': 'auto', + 'support-confirm': 'choice', + 'support-log-view': 'auto', + 'support-uploading': '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?' }, +] + +// Data-safety gate shown when saved android credentials already exist for the +// app. Mirrors main's CredentialsExistStep (android-shared.tsx): backup the +// existing credentials.json first, or stop. 'backup' → backing-up effect → +// keystore-method-select; 'cancel' → halt onboarding (main's exitOnboarding()). +const OPTIONS_CREDENTIALS_EXIST: AndroidStepOption[] = [ + { value: 'backup', label: 'Start fresh (backup existing credentials first)' }, + { value: 'cancel', label: 'Exit onboarding' }, +] + +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 { + // Post-save tail: delegate to the shared platform-neutral view and adapt its + // output back to AndroidStepView. AndroidStepCtx is a superset of TailStepCtx, + // so it threads straight through. + if (TAIL_VIEW_STEPS.has(step)) + return mapTailViewToAndroidStepView(tailViewForStep(step as TailStep, progress, ctx as TailStepCtx), step) + + // 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.' } + + // ── Data-safety gate (saved android credentials already exist) ── + // Mirrors main's CredentialsExistStep: a backup-or-cancel choice. The + // `backing-up` step is an auto effect (the credentials.json → dated copy), + // so it carries only kind (no options/prompt). + case 'credentials-exist': + return { ...base, options: OPTIONS_CREDENTIALS_EXIST } + + // ── 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 + } + + // ── 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 0 — Data-safety gate (saved android credentials already exist). + // 'backup' → mark gate for backing-up; 'cancel' → halt onboarding. + | { step: 'credentials-exist'; value: 'backup' | 'cancel' } + + // 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' } + + // ── Phase 6 — Post-save "tail" inputs ─────────────────────────────────────── + // One variant per tail choice/input step that records state on TailProgress. + // Navigation-only / spinner-gate choices (the 'retry'/'skip'/'view'/'close' + // routing values) carry no persisted field — applyAndroidInput returns + // progress unchanged for them; the driver owns the visual transition. + + // ci-secrets-setup — retry the detection or skip the upload (navigation-only). + | { step: 'ci-secrets-setup'; value: 'retry' | 'skip' } + + // ci-secrets-target-select — records the chosen destination (or skip). + | { step: 'ci-secrets-target-select'; ciSecretTarget: CiSecretTarget | null } + + // ask-ci-secrets — confirm/skip the GitLab upload (navigation-only). + | { step: 'ask-ci-secrets'; value: 'yes' | 'no' } + + // confirm-ci-secret-overwrite — replace existing or skip (navigation-only). + | { step: 'confirm-ci-secret-overwrite'; value: 'replace' | 'skip' } + + // ci-secrets-failed — retry the upload or continue (navigation-only). + | { step: 'ci-secrets-failed'; value: 'retry' | 'continue' } + + // ask-github-actions-setup — records the 3-way GitHub Actions setup mode. + | { step: 'ask-github-actions-setup'; value: 'with-workflow' | 'secrets-only' | 'declined' } + + // confirm-secrets-push — confirm/cancel the push (navigation-only). + | { step: 'confirm-secrets-push'; value: 'confirm' | 'cancel' } + + // ask-export-env — 'yes' records the resolved export path; 'no' exits. + | { step: 'ask-export-env'; value: 'no' } + | { step: 'ask-export-env'; value: 'yes'; envExportTargetPath: string } + + // confirm-env-export-overwrite — replace/skip (navigation-only). + | { step: 'confirm-env-export-overwrite'; value: 'replace' | 'skip' } + + // pick-package-manager — records the chosen package manager. + | { step: 'pick-package-manager'; selectedPackageManager: PackageManager } + + // pick-build-script — records the build-script choice; '__custom__' routes to + // the custom-command input (navigation-only) and '__skip__' records a skip. + | { step: 'pick-build-script'; value: '__custom__' } + | { step: 'pick-build-script'; buildScriptChoice: BuildScriptChoice } + + // pick-build-script-custom — records the free-text custom command. + | { step: 'pick-build-script-custom'; command: string } + + // preview-workflow-file — write / view diff / cancel (navigation-only). + | { step: 'preview-workflow-file'; value: 'write' | 'view' | 'cancel' } + + // view-workflow-diff — close the diff (navigation-only). + | { step: 'view-workflow-diff'; value: 'close' } + + // ask-build — request a build now or finish (navigation-only). + | { step: 'ask-build'; value: 'yes' | 'no' } + +// ─── 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 { + // Post-save tail: delegate the tail choice/input reducers to the shared + // platform-neutral module. The android tail AndroidInput variants are + // structurally identical to TailInput, so they thread straight through. + if (TAIL_INPUT_STEPS.has(step)) + return applyTailInput(step as TailStep, progress, input as TailInput) + + switch (step) { + // ── credentials-exist ───────────────────────────────────────────────────── + // Data-safety gate. app.tsx routes 'backup' → setStep('backing-up') and + // 'exit' → exitOnboarding(). The stateless engine encodes that choice in + // `_credentialsExistGate`: 'backup' parks the resume step on `backing-up` + // (the copy effect runs next); 'cancel' halts onboarding. + case 'credentials-exist': { + const i = input as Extract + if (i.value === 'backup') + return { ...progress, _credentialsExistGate: 'backup' } + return { ...progress, _credentialsExistGate: 'cancel' } + } + + // ── 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 + + // ── Post-save "tail" helpers (Phase 1 engine tail) ─────────────────────── + // The CI-secrets → env-export → workflow-file → build-request sub-flow that + // runs after credentials are saved. Every field is OPTIONAL and ADDITIVE: the + // driver injects the concrete helper (pre-binding any cwd/runner/config it + // owns) so the core stays IO-free. Signatures mirror the matching helper + // modules verbatim so a driver can pass the real function unchanged. Cases + // that consume these are wired in later tasks (A3/A4) — A2 only widens the + // surface; existing call sites and the MCP bridge keep type-checking because + // none of these are required. + + /** + * Build the CI-secret entries (key/value/masked) from the saved credentials. + * Mirrors ci-secrets.ts: createCiSecretEntries(credentials, apiKey?). + */ + createCiSecretEntries?: (credentials: Partial, apiKey?: string) => CiSecretEntry[] + + /** + * Detect which CI-secret destinations (GitHub/GitLab) are reachable. + * Mirrors ci-secrets.ts: detectCiSecretTargets(runner?). The driver pre-binds + * the command runner, so the core calls this with no args. + */ + detectCiSecretTargets?: (runner?: CommandRunner) => CiSecretDiscovery + + /** + * Resolve the concrete `owner/repo` (GitHub) or `group/project` (GitLab) the + * CLI will target, so the user can confirm before any secret is overwritten. + * Mirrors ci-secrets.ts: getCiSecretRepoLabelAsync(target, runner?). + */ + getCiSecretRepoLabelAsync?: (target: CiSecretTarget, runner?: AsyncCommandRunner) => Promise + + /** + * List which of `keys` already exist as secrets/variables on the remote. + * Mirrors ci-secrets.ts: listExistingCiSecretKeysAsync(target, keys, runner?). + */ + listExistingCiSecretKeysAsync?: (target: CiSecretTarget, keys: string[], runner?: AsyncCommandRunner) => Promise + + /** + * Push the CI-secret entries to the target, reporting per-key progress. + * Mirrors ci-secrets.ts: uploadCiSecretsAsync(target, entries, existingKeys?, runner?, onProgress?). + */ + uploadCiSecretsAsync?: ( + target: CiSecretTarget, + entries: CiSecretEntry[], + existingKeys?: string[], + runner?: AsyncCommandRunner, + onProgress?: (current: number, total: number, keyName: string) => void, + ) => Promise + + /** + * Write the credentials to a local 0o600 `.env` file (no git operation). + * Mirrors env-export.ts: exportCredentialsToEnv(opts). + */ + exportCredentialsToEnv?: (opts: EnvExportOpts) => EnvExportResult + + /** + * Resolve the default `.env` export path for an app + platform (pure). + * Mirrors env-export.ts: defaultExportPath(appId, platform). + */ + defaultExportPath?: (appId: string, platform: 'ios' | 'android') => string + + /** + * Generate the GitHub Actions workflow YAML (pure). + * Mirrors workflow-generator.ts: generateWorkflow(opts). + */ + generateWorkflow?: (opts: WorkflowGeneratorOpts) => GeneratedWorkflow + + /** + * Generate + write the workflow file to `.github/workflows/capgo-build.yml`. + * Mirrors workflow-writer.ts: writeWorkflowFile(opts, writeOptions?). + */ + writeWorkflowFile?: (opts: WorkflowGeneratorOpts, writeOptions?: WorkflowWriteOptions) => WorkflowWriteResult + + /** + * Fire the actual `capgo build request`. The driver pre-binds the logger / + * silent flag it owns; the core supplies appId + options. + * Mirrors request.ts: requestBuildInternal(appId, options, silent?, logger?). + */ + requestBuildInternal?: (appId: string, options: BuildRequestOptions, silent?: boolean, logger?: BuildLogger) => Promise + + // ── streaming / telemetry / preload deps (forwarded to the shared tail) ──── + // OPTIONAL/ADDITIVE — the android TUI resolves these and the engine adapter + // forwards them straight through `toTailDeps` into the shared `TailEffectDeps` + // so `runTailEffect` streams build output, reports CI-secret progress/phase, + // preloads workflow-builder scripts and fires workflow telemetry. Signatures + // mirror the matching `TailEffectDeps` fields verbatim. When absent the shared + // tail degrades gracefully (legacy behaviour), so existing callers and the MCP + // bridge keep type-checking. + + /** + * The streaming BuildLogger the TUI threads into requestBuildInternal (the 4th + * arg) so every build line streams into `setBuildOutput`. Forwarded verbatim. + */ + logger?: BuildLogger + + /** + * The build VIEWER sink — the android TUI feeds this into `setBuildOutput` (the + * dedicated build output pane, DISTINCT from the `onLog` side-log). The shared + * tail writes the build header / blank+queued / ⚠ failure / no-key UX / catch + * lines here. Forwarded verbatim through `toTailDeps`. No-op when absent. + */ + onBuildOutput?: (line: string) => void + + /** + * Resolves the Capgo API key the build request should use, mirroring the + * android tail's CLI-flag-over-saved precedence. Returns undefined when no key + * is resolvable (the no-key UX finishes at build-complete). Forwarded verbatim. + */ + resolveApikey?: () => string | undefined + + /** + * Per-key CI-secret upload progress, forwarded as the 5th arg of + * uploadCiSecretsAsync. The android tail feeds this into + * `setCiSecretUploadProgress`. No-op when absent. + */ + onCiSecretUploadProgress?: (current: number, total: number, keyName: string) => void + + /** + * The 2-phase checking-ci-secrets status text. The android tail feeds this into + * `setCiSecretCheckPhase`. No-op when absent. + */ + onCiSecretCheckPhase?: (phase: string) => void + + /** + * The ci-secrets-failed reason (repo-null / catch in checking-ci-secrets). The + * android tail feeds this into `setCiSecretError` (rendered by the + * CiSecretsFailedStep). Forwarded verbatim through `toTailDeps`. No-op when absent. + */ + onCiSecretError?: (message: string) => void + + /** Reads the project's package.json scripts map (workflow-builder preload). */ + getPackageScripts?: () => Record + /** Detects the web-framework project type (best-effort; may resolve null). */ + findProjectType?: (options?: { quiet?: boolean }) => Promise + /** Maps a detected project type to its recommended build script name. */ + findBuildCommandForProjectType?: (projectType: string) => Promise + + /** + * Workflow-file telemetry hook (e.g. 'workflow-file-written'). The android tail + * calls `trackWorkflowEvent`. No-op when absent. + */ + trackWorkflowEvent?: (event: string, options?: { decision?: string }) => void + + /** + * DRIVER-HELD transient tail state, threaded back into each post-save tail + * effect. The Ink TUI resolves these ONCE (at `saving-credentials`) and keeps + * them in React state (`savedCredentials` / `ciSecretEntries` / + * `ciSecretExistingKeys`); a headless driver mirrors that by capturing the + * matching `AndroidEffectResult.transient` from each effect and passing it + * back here on the NEXT effect. The engine NEVER persists these to + * progress.json — they are secrets/credentials/entries that must stay in + * memory only. When a field is absent (e.g. a crash-recovery resume where the + * driver lost its in-memory state) the effect falls back to a SINGLE lossy + * re-derivation from progress (rebuildTailCredentials / createCiSecretEntries) + * rather than resolving the Capgo API key a second time. + */ + carried?: { + /** Full saved credentials map written at saving-credentials (5 fields, no CAPGO_TOKEN). */ + savedCredentials?: Record + /** CI-secret entries resolved ONCE at saving-credentials (creds + Capgo API key → CAPGO_TOKEN). */ + ciSecretEntries?: CiSecretEntry[] + /** Which secret keys already exist on the remote, resolved at checking-ci-secrets. */ + ciSecretExistingKeys?: string[] + /** Whether the workflow file did NOT exist at preview (app.tsx's `previewIsNew`); drives 'Wrote' vs 'Overwrote'. Defaults to NEW when absent. */ + workflowIsNew?: boolean + } + + // ── Callbacks (optional — callers that don't need streaming can omit) ──── + onStatus?: (message: string) => void + onLog?: (message: string, color?: string) => void + /** Internal-only diagnostic line → the support internal log (main PR #2406). Optional; no-op when absent. */ + onInternalLog?: (line: 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 +} + +// ─── Tail credential threading (parity with the Ink TUI) ───────────────────── +// +// The post-save tail steps (checking-ci-secrets / uploading-ci-secrets / +// exporting-env / writing-workflow-file) need the same CI-secret entries + +// saved credentials the Ink TUI holds in `ciSecretEntries` / `savedCredentials` +// React state, resolved ONCE at `saving-credentials` (where the Capgo API key +// is folded into the entries so CAPGO_TOKEN rides along). A headless driver +// mirrors that by threading those values back through `deps.carried.*` on each +// subsequent effect — so the engine REUSES them rather than re-resolving the +// API key (which it cannot see) a second time. The `rebuildTailCredentials` +// fallback below is a SINGLE lossy re-derivation used only when the driver's +// carried state is absent (e.g. a crash-recovery resume that lost the +// in-memory React/driver state); it omits CAPGO_TOKEN, matching the worst-case +// the TUI would hit on the same path. +function rebuildTailCredentials(progress: AndroidOnboardingProgress): Record { + const keystoreBase64 = progress._keystoreBase64 + const serviceAccountKeyBase64 = progress._serviceAccountKeyBase64 + const keystoreStorePassword = progress.keystoreStorePassword + const keystoreAlias = progress.keystoreAlias + if (!keystoreBase64 || !serviceAccountKeyBase64 || !keystoreStorePassword || !keystoreAlias) + return {} + return { + ANDROID_KEYSTORE_FILE: keystoreBase64, + KEYSTORE_KEY_ALIAS: keystoreAlias, + KEYSTORE_STORE_PASSWORD: keystoreStorePassword, + KEYSTORE_KEY_PASSWORD: progress.keystoreKeyPassword || keystoreStorePassword, + PLAY_CONFIG_JSON: serviceAccountKeyBase64, + } +} + +/** + * Build the ANDROID saved-credential SHAPE written at `saving-credentials`. + * Replicates the doSaveCredentials guards verbatim (app.tsx:704–709) so the + * shared tail module stays platform-neutral while android owns its field set. + */ +function buildAndroidSavedCredentials(progress: AndroidOnboardingProgress): Record { + 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') + + return { + ANDROID_KEYSTORE_FILE: keystoreBase64, + KEYSTORE_KEY_ALIAS: keystoreAlias, + KEYSTORE_STORE_PASSWORD: keystoreStorePassword, + KEYSTORE_KEY_PASSWORD: progress.keystoreKeyPassword || keystoreStorePassword, + PLAY_CONFIG_JSON: serviceAccountKeyBase64, + } +} + +/** + * The set of post-save tail steps the android engine delegates to the shared + * platform-neutral module (`../tail/flow.js`). EXCLUDES ai-analysis-* (TUI-only) + * and the android-specific provisioning effects. Effect-bearing tail steps live + * in TAIL_EFFECT_STEPS; the view/input delegation uses TAIL_STEPS. + */ +const TAIL_EFFECT_STEPS = new Set([ + 'saving-credentials', + 'detecting-ci-secrets', + 'checking-ci-secrets', + 'uploading-ci-secrets', + 'exporting-env', + 'overwrite-and-export-env', + 'writing-workflow-file', + 'requesting-build', +]) + +const TAIL_VIEW_STEPS = new Set([ + 'ci-secrets-setup', + 'ci-secrets-target-select', + 'ask-ci-secrets', + 'confirm-ci-secret-overwrite', + 'ci-secrets-failed', + 'ask-github-actions-setup', + 'confirm-secrets-push', + 'ask-export-env', + 'confirm-env-export-overwrite', + 'pick-package-manager', + 'pick-build-script', + 'pick-build-script-custom', + 'preview-workflow-file', + 'view-workflow-diff', + 'ask-build', + 'build-complete', +]) + +const TAIL_INPUT_STEPS = new Set([ + 'ci-secrets-target-select', + 'ask-github-actions-setup', + 'ask-export-env', + 'pick-package-manager', + 'pick-build-script', + 'pick-build-script-custom', +]) + +/** + * Adapt the android effect deps to the platform-neutral TailEffectDeps. The + * shared tail module owns the routing/branching; android supplies its platform + * tag, credential SHAPE, lossy rebuild and resume resolver, plus the same + * injected helpers (passed straight through). + */ +function toTailDeps(deps: AndroidEffectDeps): TailEffectDeps { + return { + platform: 'android', + buildSavedCredentials: buildAndroidSavedCredentials, + rebuildTailCredentials, + resumeStep: getAndroidResumeStep, + updateSavedCredentials: deps.updateSavedCredentials, + loadProgress: deps.loadAndroidProgress, + saveProgress: deps.saveAndroidProgress, + deleteProgress: deps.deleteAndroidProgress, + createCiSecretEntries: deps.createCiSecretEntries, + detectCiSecretTargets: deps.detectCiSecretTargets, + getCiSecretRepoLabelAsync: deps.getCiSecretRepoLabelAsync, + listExistingCiSecretKeysAsync: deps.listExistingCiSecretKeysAsync, + uploadCiSecretsAsync: deps.uploadCiSecretsAsync, + exportCredentialsToEnv: deps.exportCredentialsToEnv, + defaultExportPath: deps.defaultExportPath, + generateWorkflow: deps.generateWorkflow, + writeWorkflowFile: deps.writeWorkflowFile, + requestBuildInternal: deps.requestBuildInternal, + // ── streaming / telemetry / preload deps forwarded verbatim ────────────── + logger: deps.logger, + onBuildOutput: deps.onBuildOutput, + resolveApikey: deps.resolveApikey, + onCiSecretUploadProgress: deps.onCiSecretUploadProgress, + onCiSecretCheckPhase: deps.onCiSecretCheckPhase, + onCiSecretError: deps.onCiSecretError, + getPackageScripts: deps.getPackageScripts, + findProjectType: deps.findProjectType, + findBuildCommandForProjectType: deps.findBuildCommandForProjectType, + trackWorkflowEvent: deps.trackWorkflowEvent, + carried: deps.carried, + onStatus: deps.onStatus, + onLog: deps.onLog, + onInternalLog: deps.onInternalLog, + signal: deps.signal, + } +} + +/** Map the shared TailStepView back onto AndroidStepView (same field shape). */ +function mapTailViewToAndroidStepView(v: TailStepView, step: AndroidOnboardingStep): AndroidStepView { + return { + step, + kind: v.kind, + title: v.title, + prompt: v.prompt, + collect: v.collect, + options: v.options, + message: v.message, + } +} + +// ─── 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 { + // Post-save tail: delegate to the platform-neutral shared module. The tail + // routing/branching/transient-threading lives there once for ios + android; + // android supplies its platform tag + credential SHAPE via toTailDeps. The + // returned neutral result maps 1:1 onto AndroidEffectResult (next is a wider + // AndroidOnboardingStep; transient is a subset of AndroidStepCtx). + if (TAIL_EFFECT_STEPS.has(step)) { + const result = await runTailEffect(step as TailStep, progress, toTailDeps(deps)) + return { + progress: result.progress, + next: result.next as AndroidOnboardingStep | undefined, + transient: result.transient, + } + } + + switch (step) { + // ── backing-up ──────────────────────────────────────────────────────── + // app.tsx:1017–1035. Copy the existing ~/.capgo-credentials/credentials.json + // to a timestamped sibling before the keystore phase overwrites it, then + // advance to keystore-method-select. A missing source file is non-fatal + // (yellow warning) — the gate's promise was "backup first", not "must exist". + // The gate transitions to 'done' so resume falls through to keystore. + case 'backing-up': { + const credPath = join(homedir(), '.capgo-credentials', 'credentials.json') + const date = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + const backupPath = join(homedir(), '.capgo-credentials', `credentials-${date}.copy.json`) + try { + await deps.copyFile(credPath, backupPath) + deps.onLog?.(`✔ Backup saved · ${backupPath}`) + } + catch (err) { + const reason = err instanceof Error ? err.message : String(err) + deps.onInternalLog?.(`credentials backup failed: ${reason}`) + // Only ENOENT means "nothing to back up yet" — that stays benign. Any + // OTHER failure (EACCES, disk full, …) keeps the proceed-anyway gate + // contract but must report the REAL reason instead of pretending the + // file may not exist (hostile-review, 2026-06-12). + const missingSource = (err as { code?: string } | null)?.code === 'ENOENT' || /ENOENT/.test(reason) + if (missingSource) + deps.onLog?.('⚠ Could not backup credentials (file may not exist yet)', 'yellow') + else + deps.onLog?.(`⚠ Could not back up credentials: ${reason}`, 'yellow') + } + const nextProgress: AndroidOnboardingProgress = { ...progress, _credentialsExistGate: 'done' } + await deps.saveAndroidProgress(progress.appId, nextProgress) + return { progress: nextProgress, next: 'keystore-method-select' } + } + + // ── 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 }, + } + } + + // ── 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)...') + let revoked = false + try { + await deps.revokeToken(currentProgress._oauthRefreshToken) + revoked = true + 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`) + } + if (revoked) { + // The token is now dead — strip it from progress and persist, so a + // revoked credential doesn't linger in progress.json (CodeRabbit, + // 2026-06-12). On a failed revoke it is KEPT: it still expires on + // its own and a crash-resume re-enters this step to retry. + const { _oauthRefreshToken: _revoked, ...withoutToken } = currentProgress + currentProgress = withoutToken + await deps.saveAndroidProgress(currentProgress.appId, currentProgress) + } + } + + 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 6b10528560..fe5385bfee 100644 --- a/cli/src/build/onboarding/android/oauth-google.ts +++ b/cli/src/build/onboarding/android/oauth-google.ts @@ -496,16 +496,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) @@ -521,59 +542,82 @@ 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 (err) { + appendInternalLog(`google sign-in: could not auto-open browser: ${err instanceof Error ? err.message : String(err)}`) + 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 (err) { - appendInternalLog(`google sign-in: could not auto-open browser: ${err instanceof Error ? err.message : String(err)}`) - 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...') + + 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 + } - const { code, finishResponse } = await server.code + // 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) + } - options.onStatus?.('Exchanging code for tokens...') - let tokens: GoogleOAuthTokens - try { - tokens = await exchangeAuthCode({ - config, - code, - codeVerifier: pkce.verifier, - redirectUri: server.redirectUri, - }) + 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..3faec1a3df 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' @@ -125,6 +132,84 @@ export function hasAnyOAuthProgress(progress: AndroidOnboardingProgress): boolea ) } +/** + * Post-save "tail" resume routing. + * + * Once `saving-credentials` completes, the wizard runs a shared tail: + * ask-build → requesting-build → detecting-ci-secrets → (ci-secrets sub-flow) + * → uploading-ci-secrets → (with-workflow) pick-package-manager → + * pick-build-script → preview/writing-workflow-file → build-complete + * with a parallel `.env`-export leaf (ask-export-env → exporting-env) taken when + * the user declines the GitHub Actions setup. This derivation mirrors the tail + * `useEffect` + view handlers in `android/ui/app.tsx` step-for-step. + * + * Resume here is GUARDED by the three irreversible-side-effect markers + * (`credentialsSaved`, `buildRequested`, `ciSecretsUploaded`). The router never + * returns a step that would re-fire a side-effect that already has its marker: + * - no `buildRequested` → land on the `ask-build` user gate (never auto-fire + * `requesting-build`, so resume can't double-build) + * - `buildRequested` but no `ciSecretsUploaded` → land on the read-only + * `detecting-ci-secrets` (or `checking-ci-secrets` + * once a target is chosen) — never `uploading-ci-secrets` + * - `ciSecretsUploaded` → only the (idempotent) workflow-builder choice/input + * steps or the terminal `build-complete` remain + * + * Returns `null` when no tail marker is present, so `getAndroidResumeStep` keeps + * returning `saving-credentials` exactly as before — legacy/in-flight progress + * files (which never carry these markers) are completely unaffected. + */ +function tailResumeStep(progress: AndroidOnboardingProgress): AndroidOnboardingStep | null { + const { completedSteps } = progress + + // Tail not entered yet — let the caller fall through to `saving-credentials`. + if (!completedSteps.credentialsSaved) + return null + + // Phase 6a — Build request. The TUI's post-save entry point is the `ask-build` + // user gate; the build itself fires only after the user confirms. Resuming + // onto the gate (not `requesting-build`) is what prevents a double build on + // resume. + if (!completedSteps.buildRequested) + return 'ask-build' + + // Phase 6b — CI-secrets push. Not yet uploaded: route forward toward the + // upload without ever landing on the upload step itself. + if (!completedSteps.ciSecretsUploaded) { + // The user declined GitHub Actions → the `.env`-export leaf. Resume onto the + // export prompt until a path is recorded, then onto the (overwrite-safe) + // write effect. + if (progress.setupMode === 'declined') + return progress.envExportTargetPath ? 'exporting-env' : 'ask-export-env' + + // A destination is already chosen (single-target auto-pick, the + // target-select screen, or a decided setupMode) → the remote check is the + // next read-only step before the confirm gate + upload. + if (progress.ciSecretTarget) + return 'checking-ci-secrets' + + // Credentials saved + build queued but no CI work started yet → re-run the + // read-only detection. Idempotent: it only inspects the repo and routes. + return 'detecting-ci-secrets' + } + + // Phase 6c — Post-upload. Secrets are pushed; only the workflow-builder sub- + // flow (with-workflow) or the terminal screen remain. None of these re-touch + // the remote, so routing by which choice is still missing is side-effect-safe. + if (progress.setupMode === 'with-workflow') { + if (!progress.selectedPackageManager) + return 'pick-package-manager' + if (!progress.buildScriptChoice) + return 'pick-build-script' + // Package manager + build script chosen → ready to (over)write the workflow + // file. `writing-workflow-file` writes with overwrite=true, so re-running it + // is safe. + return 'writing-workflow-file' + } + + // secrets-only / declined-after-upload / GitLab → nothing left to do. + return 'build-complete' +} + /** * Determine the first incomplete step for the Android flow. * @@ -147,10 +232,35 @@ export function getAndroidResumeStep(progress: AndroidOnboardingProgress | null) const { completedSteps } = progress + // Phase 0 — Data-safety gate (shared engine). When saved android credentials + // already exist, the engine routes through `credentials-exist` (backup-or- + // cancel choice) and, on backup, `backing-up` (the credentials.json → dated + // copy) BEFORE entering the keystore phase — mirroring main's ink TUI. The + // gate lifecycle lives in `_credentialsExistGate`; only 'pending'/'backup' + // park the user on a gate step. 'done'/'cancel'/undefined fall through to the + // normal keystore routing (the engine handles 'cancel' as a hard stop). + if (progress._credentialsExistGate === 'pending') + return 'credentials-exist' + if (progress._credentialsExistGate === 'backup') + return 'backing-up' + // Phase 1 — Keystore: marker + 3 ephemeral fields if (!keystoreFullyValid(progress)) return keystoreResumeStep(progress) + // Phase 6 — Post-save "tail" (shared with iOS). `credentialsSaved` is the + // unambiguous "save already happened" marker: it means the whole provisioning + // sequence (OAuth or imported-SA) finished and credentials.json was written, + // so we route THROUGH the tail (build-request → CI-secrets → env/workflow) + // instead of past the service-account fork below. Checking it here — before + // the fork — keeps both the OAuth and import paths converging on one tail + // router. When the marker is absent (every legacy/in-flight progress file), + // `tailResumeStep` returns null and we fall through to the unchanged + // provisioning routing below. + const tailStep = tailResumeStep(progress) + if (tailStep) + return tailStep + // Phase 2 — Service-account fork. Routes onto the import path or the OAuth // path. Legacy progress files don't have `serviceAccountMethod` — treat // those as OAuth (existing behavior) so in-flight onboardings continue diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index 021a8ce5ec..d79e680cda 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -1,5 +1,7 @@ // src/build/onboarding/android/types.ts +import type { TailProgress } from '../tail-types.js' + export type AndroidOnboardingStep = | 'welcome' | 'resume-prompt' @@ -144,7 +146,29 @@ export interface AndroidPackageChoice { source: 'gradle' | 'capacitor-config' | 'user-input' } -export interface AndroidOnboardingProgress { +// ── Post-save "tail" completion markers ────────────────────────────────────── +// One marker per irreversible tail side-effect. They mirror the provisioning +// markers above (small, JSON-serializable proof-of-completion objects) so the +// resume router can skip past a step that already ran without re-firing it. + +export interface CredentialsSaved { + /** ISO timestamp credentials.json was written — purely informational. */ + savedAt: string +} + +export interface BuildRequested { + /** The build dashboard URL surfaced after the queue request succeeded. */ + buildUrl: string +} + +export interface CiSecretsUploaded { + /** Provider the secrets were pushed to (matches the chosen ciSecretTarget). */ + provider: 'github' | 'gitlab' + /** How many env vars were pushed — informational. */ + count: number +} + +export interface AndroidOnboardingProgress extends TailProgress { platform: 'android' appId: string startedAt: string @@ -188,6 +212,26 @@ export interface AndroidOnboardingProgress { androidPackageChosen?: AndroidPackageChoice serviceAccountProvisioned?: ServiceAccountProvisioned playInviteProvisioned?: PlayInviteProvisioned + // ── Post-save "tail" milestones (additive — present only once the + // matching side-effecting tail step has finished). They let + // `getAndroidResumeStep` route a saved progress THROUGH the tail + // (CI-secrets → env-export → workflow-file → build-request) without + // re-running a side-effecting step. Each is a marker in the same + // self-healing style as the provisioning markers above: presence means + // "this irreversible step already happened — do NOT do it again". + // + // The headless engine deletes the on-disk progress file at + // `saving-credentials` today, so these markers are written by whichever + // driver chooses to persist the tail for resume (the Ink TUI migration in + // a later phase). When absent, resume falls through to `saving-credentials` + // exactly as before — so legacy/in-flight progress files are unaffected. + + /** Set once `saving-credentials` wrote credentials.json. Gates tail entry. */ + credentialsSaved?: CredentialsSaved + /** Set once `requesting-build` queued a build. Guards a double build-request. */ + buildRequested?: BuildRequested + /** Set once `uploading-ci-secrets` pushed the secrets. Guards a re-upload. */ + ciSecretsUploaded?: CiSecretsUploaded } // Ephemeral — wiped when onboarding finishes. Held on disk only so resume @@ -196,6 +240,27 @@ export interface AndroidOnboardingProgress { _keystoreBase64?: string /** Base64 of the downloaded SA JSON key — saved as PLAY_CONFIG_JSON at end. */ _serviceAccountKeyBase64?: string + // ── MCP markers (added by the shared engine; harmless to the ink TUI) ── + /** Platform whose onboarding is currently in-flight. */ + activePlatform?: 'android' + /** True when the new-keystore password was auto-generated (random). Never logged. */ + keystorePasswordGenerated?: boolean + /** True once the user chose the MANUAL password method (lets the stateless MCP advance). */ + keystorePasswordManual?: boolean + /** + * Data-safety gate state for the shared engine (mirrors main's ink TUI + * `credentials-exist` → `backing-up` flow). The Ink driver does not read this + * field — it gates the same situation via React state — so it is harmless to + * the TUI. Lifecycle: + * - undefined → gate not yet evaluated (or no saved credentials exist) + * - 'pending' → saved android credentials exist; awaiting the user's + * backup-or-cancel choice (the `credentials-exist` step) + * - 'backup' → user chose backup; the `backing-up` effect must still run + * - 'done' → backup performed (or source absent); proceed to keystore + * - 'cancel' → user chose to stop; onboarding halts to protect the + * existing credentials (mirrors main's exitOnboarding()) + */ + _credentialsExistGate?: 'pending' | 'backup' | 'done' | 'cancel' } export const ANDROID_STEP_PROGRESS: Record = { diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 31d0981077..3fc160bb7e 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -120,11 +120,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, @@ -136,7 +132,6 @@ import { generateKeystore, generateRandomPassword, listKeystoreAliases, tryUnloc import { fetchUserInfo, GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, - MissingScopesError, refreshAccessToken, revokeToken, runOAuthFlow, @@ -154,14 +149,14 @@ 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 type { AndroidEffectDeps, AndroidInput } from '../flow.js' +import { applyAndroidInput, runAndroidEffect } from '../flow.js' interface LogEntry { text: string, color?: string } @@ -181,6 +176,53 @@ interface AppProps { const RELEASE_ALIAS_DEFAULT = 'release' +// ─── ENGINE_AUTO_FAILED_STEP ────────────────────────────────────────────────── +// +// The engine-driven 'auto' steps routed through the shared `runAndroidEffect` +// (Plan 3.2). The map's PRESENCE of a key marks the step as engine-routed; the +// VALUE is the `failedStep` passed to `handleError` when the engine throws +// (matching the failedStep each original per-step effect used). `undefined` +// reproduces the original effect's best-effort no-catch behavior (the +// android-package-select pre-load swallowed errors). +// +// Steps the engine does NOT yet implement (sa-json-validating, saving-credentials, +// gcp-projects-loading, the CI / env / workflow / build tail) and the TUI-only +// auto steps are intentionally absent — they keep their bespoke TUI effects. +const ENGINE_AUTO_FAILED_STEP: { [K in AndroidOnboardingStep]?: AndroidOnboardingStep | undefined } = { + 'backing-up': 'backing-up', + 'keystore-existing-detecting-alias': 'keystore-existing-path', + 'keystore-generating': 'keystore-generating', + 'google-sign-in-running': 'google-sign-in', + 'gcp-setup-running': 'gcp-setup-running', + 'android-package-select': undefined, +} + +// ─── TAIL_DRIVER_STEPS ──────────────────────────────────────────────────────── +// +// The post-save "tail" AUTO steps the TUI delegates to the shared engine's +// `runAndroidEffect` (which routes them into the platform-neutral tail module). +// Unlike the early/mid engine-driven steps (ENGINE_AUTO_FAILED_STEP) these run +// AFTER saving-credentials has DELETED progress.json, so the driver feeds the +// engine a SYNTHETIC progress carrying the in-memory React tail state +// (setupMode / ciSecretTarget / selectedPackageManager / buildScriptChoice / +// envExportTargetPath / keystorePasswordGenerated) and threads the transient +// (savedCredentials / ciSecretEntries / ciSecretExistingKeys / workflowIsNew) +// back via deps.carried — the engine NEVER re-creates progress.json here. +// +// ai-analysis-* + the build-log viewer stay ink-only (no AI-calling-AI in the +// headless engine); the requesting-build → ai-analysis-prompt handoff still +// reaches the AI UI because the engine returns next: 'ai-analysis-prompt'. +const TAIL_DRIVER_STEPS = new Set([ + 'saving-credentials', + 'detecting-ci-secrets', + 'checking-ci-secrets', + 'uploading-ci-secrets', + 'exporting-env', + 'overwrite-and-export-env', + 'writing-workflow-file', + 'requesting-build', +]) + /** 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 @@ -229,6 +271,13 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir : startStep, ) + // Mirror `step` into a ref so callbacks that must not depend on `step` + // (e.g. `persistAndStep`'s error handler) can read the current step without + // re-creating on every transition. Updated synchronously on each render so it + // always reflects the latest committed step. + const stepRef = useRef(step) + stepRef.current = step + // Telemetry: resolve org id once + emit per-step events const stepTimingRef = useRef<{ step: AndroidOnboardingStep | null, startedAt: number }>({ step: null, @@ -450,11 +499,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir 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( - initialProgress?.completedSteps.keystoreReady || null, - ) - const [keystoreBase64, setKeystoreBase64] = useState(initialProgress?._keystoreBase64 || '') + const [, setKeystoreCommonName] = useState(initialProgress?.keystoreCommonName || '') + // Plan 3.3: keystoreReady / _keystoreBase64 are no longer mirrored in React + // state — doSaveCredentials reads them straight from the freshly-loaded + // on-disk progress (the single source of truth). They're still persisted to + // progress.json by the keystore effects/handlers below. const [randomPasswordGenerated, setRandomPasswordGenerated] = useState(false) const [detectedAliases, setDetectedAliases] = useState([]) /** Phase 1.5 — key-password auto-skip probe. `null` = haven't decided yet, @@ -464,10 +513,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [keyPasswordProbe, setKeyPasswordProbe] = useState(null) const keyPasswordProbeRef = useRef(false) - // Phase 2 — Service account method fork - const [serviceAccountMethod, setServiceAccountMethod] = useState<'existing' | 'generate' | null>( - initialProgress?.serviceAccountMethod || null, - ) + // Phase 2 — Service account method fork. The chosen method lives on disk + // (progress.serviceAccountMethod); there is no React mirror (Plan 3.4) — every + // reader resolves it from initialProgress or freshly-loaded progress. const [serviceAccountJsonPath, setServiceAccountJsonPath] = useState( initialProgress?.serviceAccountJsonPath || '', ) @@ -491,7 +539,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 @@ -500,7 +548,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( @@ -523,9 +571,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [, setPlayInviteProvisioned] = useState( initialProgress?.completedSteps.playInviteProvisioned || null, ) - const [serviceAccountKeyBase64, setServiceAccountKeyBase64] = useState( - initialProgress?._serviceAccountKeyBase64 || '', - ) + // Plan 3.3: _serviceAccountKeyBase64 is no longer mirrored in React state — + // doSaveCredentials reads it from the freshly-loaded on-disk progress. It's + // still persisted to progress.json by the SA effects/handlers below. // Phase 6 — build output const [buildUrl, setBuildUrl] = useState('') @@ -735,10 +783,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir }) const persist = useCallback( - async (updater: (p: AndroidOnboardingProgress) => AndroidOnboardingProgress) => { + async (updater: (p: AndroidOnboardingProgress) => AndroidOnboardingProgress): Promise => { const existing = (await loadAndroidProgress(appId)) || emptyProgress(appId) const next = updater(existing) await saveAndroidProgress(appId, next) + return next }, [appId], ) @@ -766,26 +815,103 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const persistAndStep = useCallback( ( updater: (p: AndroidOnboardingProgress) => AndroidOnboardingProgress, - nextStep: AndroidOnboardingStep, + // OPTIONAL. Omit to let the shared engine derive the next step from the + // just-saved progress via `getAndroidResumeStep` (the same function used + // for initial-step resolution at mount). Pass an explicit step ONLY when + // the target is a TUI-only step the engine cannot derive (e.g. the + // intermediate `keystore-new-key-password` input, or the `keystore- + // generating` effect screen) — those advance the wizard to a screen the + // stateless engine does not model. + nextStep?: AndroidOnboardingStep, ): void => { ;(async () => { try { - await persist(updater) - setStep(nextStep) + // Plan 3.4: in-session sequencing flows through the shared engine. + // `persist` returns the just-saved progress; when no explicit + // `nextStep` is given we derive the next step from it — Phase 3.1's + // smoke + dev mismatch guard confirmed the engine derives the same + // step the TUI historically hardcoded for these call sites. An + // explicit `nextStep` is honored verbatim for TUI-only targets the + // engine cannot derive. + const saved = await persist(updater) + setStep(nextStep ?? getAndroidResumeStep(saved)) } catch (err) { // saveAndroidProgress failures (disk full, permission, etc.) used to // become unhandled rejections and stall the UI silently. Route them - // through the same retry/error UX as inline await failures. The - // failedStep is `nextStep` because we never advanced — on resume, - // getAndroidResumeStep recomputes from progress.json anyway. - handleErrorRef.current?.(err, nextStep) + // through the same retry/error UX as inline await failures. We never + // advanced, so the failed step is the explicit `nextStep` (TUI-only + // target) or the step the user is currently on (`stepRef` mirrors + // `step` without making it a dep of this callback). On resume, + // getAndroidResumeStep recomputes from progress.json anyway, so this + // only governs the immediate retry target. + handleErrorRef.current?.(err, nextStep ?? stepRef.current) } })() }, [persist], ) + // ─── Engine-derived tail routing (choice/input → next step) ───────────────── + // + // The post-save tail CHOICE/INPUT steps record their field in React state (the + // synthetic-progress tail driver above reads it back), then advance. Rather + // than hardcode the next step in each handler, the MATCH-class tail inputs (see + // test/test-android-tail-routing.mjs) derive it the same way the engine does: + // + // getAndroidResumeStep(applyAndroidInput(step, syntheticProgress, input)) + // + // `syntheticProgress` overlays the in-memory React tail state onto a base that + // (a) PASSES the keystore gate — we are provably past it in the tail, so the + // gate-passing markers are stubbed — and (b) carries the tail phase markers + // (`credentialsSaved` always; `buildRequested` / `ciSecretsUploaded` per the + // step's position) that `getAndroidResumeStep` reads to enter `tailResumeStep`. + // The stub keystore values are never inspected by the tail router; only their + // presence matters (it short-circuits the early keystore phase). This mirrors + // the fixtures in the routing-parity test step-for-step. + // + // ONLY MATCH-class options are routed through here. DIVERGE-class options + // (confirm gates, preview gates, transient viewers, provider fan-outs, + // navigation-only) keep their explicit `setStep` — the resume router + // deliberately collapses those onto the nearest idempotent re-entry point, so + // engine-deriving them would change the in-session destination. + const tailEngineNext = ( + step: AndroidOnboardingStep, + input: AndroidInput, + markers: { buildRequested?: boolean, ciSecretsUploaded?: boolean }, + ): AndroidOnboardingStep => { + // Stub markers that satisfy `keystoreFullyValid` so the router skips the + // keystore phase and reaches the tail. Presence-only — values are inert. + const keystoreReadyStub: KeystoreReady = { + keystorePath: 'android/app/release.keystore', + alias: keystoreAlias || 'release', + isGenerated: true, + } + const synthetic: AndroidOnboardingProgress = { + platform: 'android', + appId, + startedAt: new Date().toISOString(), + _keystoreBase64: '_', + keystoreAlias: keystoreAlias || 'release', + keystoreStorePassword: keystoreStorePassword || '_', + // ── in-memory React tail inputs the router reads (mirrors tailProgress) ── + setupMode, + ciSecretTarget, + selectedPackageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), + buildScriptChoice, + envExportTargetPath, + completedSteps: { + keystoreReady: keystoreReadyStub, + credentialsSaved: { savedAt: new Date().toISOString() }, + ...(markers.buildRequested ? { buildRequested: { buildUrl: buildUrl || '' } } : {}), + ...(markers.ciSecretsUploaded + ? { ciSecretsUploaded: { provider: ciSecretTarget?.provider ?? 'github', count: ciSecretEntries.length } } + : {}), + }, + } + return getAndroidResumeStep(applyAndroidInput(step, synthetic, input)) + } + // Re-emit the breadcrumb entries the user "earned" before this session — the // partial keystore inputs (path / alias / store + key password) and the // completed-phase markers (sign-in, Play account, GCP project, package, SA). @@ -890,14 +1016,14 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setKeystoreStorePassword('') setKeystoreKeyPassword('') setKeystoreCommonName('') - setKeystoreReady(null) - setKeystoreBase64('') + // keystoreReady / _keystoreBase64 no longer have React mirrors (Plan 3.3); + // deleteAndroidProgress above clears them from disk. setRandomPasswordGenerated(false) setDetectedAliases([]) setKeyPasswordProbe(null) keyPasswordProbeRef.current = false - // Phase 2 — service-account fork - setServiceAccountMethod(null) + // Phase 2 — service-account fork. serviceAccountMethod has no React mirror + // (Plan 3.4); deleteAndroidProgress above clears it from disk. setSaJsonPathMode('choose') setServiceAccountJsonPath('') setSaValidationResult(null) @@ -923,7 +1049,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir // Phase 5 — provisioning outputs setServiceAccountProvisioned(null) setPlayInviteProvisioned(null) - setServiceAccountKeyBase64('') + // _serviceAccountKeyBase64 no longer has a React mirror (Plan 3.3); cleared from disk above. }, [appId]) const handleError = useCallback( @@ -1074,28 +1200,6 @@ 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 - } - useEffect(() => { let cancelled = false @@ -1131,27 +1235,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setTimeout(() => { if (!cancelled) exit() }, 2000) } - if (step === 'backing-up') { - ;(async () => { - const credPath = join(homedir(), '.capgo-credentials', 'credentials.json') - const date = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) - const backupPath = join(homedir(), '.capgo-credentials', `credentials-${date}.copy.json`) - try { - await copyFile(credPath, backupPath) - if (cancelled) - return - addLog(`✔ Backup saved · ${backupPath}`) - } - catch (err) { - appendInternalLog(`credentials backup failed: ${err instanceof Error ? err.message : String(err)}`) - if (cancelled) - return - addLog('⚠ Could not backup credentials (file may not exist yet)', 'yellow') - } - setStep('keystore-method-select') - })() - } - if (step !== 'keystore-existing-picker') pickerOpenedRef.current = false if (step !== 'google-sign-in-running') @@ -1244,7 +1327,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (result.ok) { const base64 = jsonBytes.toString('base64') - setServiceAccountKeyBase64(base64) + // _serviceAccountKeyBase64 persisted below — no React mirror (Plan 3.3). setSaValidationResult({ ok: true }) trackAction('android_sa_validation_result', { result: 'success' }, 'sa-json-validating') await persist((p) => ({ @@ -1287,47 +1370,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } - if (step === 'keystore-existing-detecting-alias') { - ;(async () => { - try { - const bytes = await readFile(keystoreExistingPath) - if (cancelled) - return - const listed = listKeystoreAliases(bytes, keystoreStorePassword) - 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') { - 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') - } - catch (err) { - if (!cancelled) - handleError(err, 'keystore-existing-path') - } - })() - } - // Reset the key-password probe whenever the user leaves the step. if (step !== 'keystore-existing-key-password') { keyPasswordProbeRef.current = false @@ -1393,8 +1435,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir alias: keystoreAlias || RELEASE_ALIAS_DEFAULT, isGenerated: false, } - setKeystoreBase64(base64) - setKeystoreReady(ready) + // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, @@ -1425,149 +1466,11 @@ 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' }, - }) - 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. - if (cancelled) - return - setStep('service-account-method-select') - } - catch (err) { - if (!cancelled) - handleError(err, 'keystore-generating') - } - })() - } - - if (step === 'google-sign-in-running' && !oauthStartedRef.current) { - oauthStartedRef.current = true - ;(async () => { - 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]) - }, - }, - ) - if (cancelled) - return - if (!tokens.refreshToken) - throw new Error('Google did not return a refresh token — try again.') - - const info = await fetchUserInfo(tokens.accessToken) - 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}`) - setRetryCount(0) - setStep('play-developer-id-input') - } - 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') - } - })() - } - // Reset the dev-ID step's sub-screen whenever we leave and come back // (e.g. after a retry from the error screen). if (step !== 'play-developer-id-input' && playDevIdMode === 'input') setPlayDevIdMode('actions') - if (step === 'android-package-select' && !packageLoadedRef.current) { - packageLoadedRef.current = true - ;(async () => { - const gradleIds = await findAndroidApplicationIds(androidDir) - if (cancelled) - return - setDetectedPackageIds(gradleIds) - })() - } - if (step === 'gcp-projects-loading') { ;(async () => { try { @@ -1585,341 +1488,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } - if (step === 'gcp-setup-running' && !setupStartedRef.current) { - setupStartedRef.current = true - ;(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) - 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, - }) - 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`) - setRetryCount(0) - setStep('saving-credentials') - } - catch (err) { - if (!cancelled) - handleError(err, 'gcp-setup-running') - } - })() - } - - 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() - if (cancelled) - return - // 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. - 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') - } - catch (err) { - if (!cancelled) - handleError(err, 'saving-credentials') - } - })() - } - - if (step === 'detecting-ci-secrets') { - ;(async () => { - try { - const discovery = detectCiSecretTargets() - if (cancelled) - return - setCiSecretTargets(discovery.targets) - setCiSecretSetupAdvice(discovery.setup) - if (discovery.targets.length === 0) { - if (discovery.setup.length > 0) { - setStep('ci-secrets-setup') - return - } - for (const note of discovery.notes) - addLog(`ℹ ${note}`, 'yellow') - setStep('build-complete') - return - } - if (discovery.targets.length === 1) { - const target = discovery.targets[0] - setCiSecretTarget(target) - // GitHub → new 3-option flow; GitLab → keep existing 2-option flow. - // Workflow generation for GitLab CI is out of scope for v1. - setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets') - return - } - setStep('ci-secrets-target-select') - } - catch (err) { - if (!cancelled) { - setCiSecretError(err instanceof Error ? err.message : String(err)) - setStep('ci-secrets-failed') - } - } - })() - } - - if (step === 'checking-ci-secrets') { - ;(async () => { - try { - if (!ciSecretTarget) - throw new Error('No git hosting target selected.') - // Phase 1: resolve target repo via async gh — non-blocking so the - // spinner keeps animating. - setCiSecretCheckPhase('Resolving GitHub repository…') - let repoLabel: string | null = null - if (ciSecretTarget.provider === 'github') { - repoLabel = await getCiSecretRepoLabelAsync(ciSecretTarget) - if (cancelled) - return - if (!repoLabel) { - setCiSecretRepoLabel(null) - setCiSecretError('Could not resolve the GitHub repository. Run `gh repo view` from this directory, then try again.') - setStep('ci-secrets-failed') - return - } - setCiSecretRepoLabel(repoLabel) - } - // Phase 2: list existing secrets. - setCiSecretCheckPhase(repoLabel - ? `Checking existing env vars in ${repoLabel}…` - : `Checking existing env vars in ${getCiSecretTargetLabel(ciSecretTarget)}…`) - const existing = await listExistingCiSecretKeysAsync(ciSecretTarget, ciSecretEntries.map(entry => entry.key)) - if (cancelled) - return - setCiSecretExistingKeys(existing) - if (ciSecretTarget.provider === 'github') { - setStep('confirm-secrets-push') - return - } - setStep(existing.length > 0 ? 'confirm-ci-secret-overwrite' : 'uploading-ci-secrets') - } - catch (err) { - if (!cancelled) { - setCiSecretError(err instanceof Error ? err.message : String(err)) - setStep('ci-secrets-failed') - } - } - })() - } - - if (step === 'uploading-ci-secrets') { - ;(async () => { - try { - if (!ciSecretTarget) - throw new Error('No git hosting target selected.') - await uploadCiSecretsAsync( - ciSecretTarget, - ciSecretEntries, - ciSecretExistingKeys, - undefined, - (current, total, key) => { - if (!cancelled) - setCiSecretUploadProgress({ current, total, key }) - }, - ) - if (cancelled) - return - setCiSecretUploadProgress(null) - const summary = `Uploaded ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'} to ${getCiSecretTargetLabel(ciSecretTarget)}` - setCiSecretUploadSummary(summary) - addLog(`✔ ${summary}`) - // Branch on what the user picked at ask-github-actions-setup. GitLab - // path leaves setupMode='undecided' and falls through to build-complete. - if (setupMode === 'with-workflow') { - try { - const scripts = getPackageScripts() ?? {} - setAvailableScripts(scripts) - const projectType = await findProjectType({ quiet: true }).catch(() => null) - if (projectType) { - const recommended = await findBuildCommandForProjectType(projectType).catch(() => null) - if (recommended && Object.hasOwn(scripts, recommended)) - setRecommendedScript(recommended) - } - } - catch (err) { - appendInternalLog(`build-script detection failed, falling back to manual entry: ${err instanceof Error ? err.message : String(err)}`) - // Best-effort; pick-build-script falls back to empty list + escape hatches. - } - // Ask the user to confirm the package manager before we build the workflow. - setStep('pick-package-manager') - return - } - setStep('build-complete') - } - catch (err) { - if (!cancelled) { - setCiSecretError(err instanceof Error ? err.message : String(err)) - setStep('ci-secrets-failed') - } - } - })() - } - if (step === 'preview-workflow-file') { ;(() => { try { @@ -1964,197 +1532,6 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })() } - if (step === 'writing-workflow-file') { - ;(() => { - try { - if (!buildScriptChoice) - throw new Error('Internal error: no build script choice recorded.') - const result = writeWorkflowFile( - { - appId, - defaultPlatform: 'android', - packageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), - buildScript: buildScriptChoice, - secretKeys: ciSecretEntries.map(entry => entry.key), - }, - { overwrite: true }, - ) - if (cancelled) - return - if (result.kind === 'written') { - setWorkflowWrittenPath(result.absolutePath) - addLog(`✔ ${previewIsNew ? 'Wrote' : 'Overwrote'} ${WORKFLOW_PATH}`) - trackWorkflowEvent('workflow-file-written', { decision: 'write' }) - } - setTimeout(() => { - if (!cancelled) - setStep('build-complete') - }, 150) - } - catch (err) { - if (!cancelled) { - addLog(`⚠ Failed to write workflow file: ${err instanceof Error ? err.message : String(err)}`, 'yellow') - setTimeout(() => { - if (!cancelled) - setStep('build-complete') - }, 150) - } - } - })() - } - - if (step === 'exporting-env') { - ;(() => { - try { - const targetPath = envExportTargetPath || defaultExportPath(appId, 'android') - const result = exportCredentialsToEnv({ - appId, - platform: 'android', - credentials: savedCredentials ?? {}, - targetPath, - }) - if (cancelled) - return - if (result.kind === 'empty') { - setEnvExportError('No credentials to export — saved state is empty.') - setStep('build-complete') - return - } - if (result.kind === 'exists') { - setEnvExportTargetPath(result.path) - setStep('confirm-env-export-overwrite') - return - } - setEnvExportPath(result.path) - addLog(`✔ Exported ${result.fieldCount} field${result.fieldCount === 1 ? '' : 's'} → ${result.path}`) - setStep('build-complete') - } - catch (err) { - if (!cancelled) { - setEnvExportError(err instanceof Error ? err.message : String(err)) - setStep('build-complete') - } - } - })() - } - - if (step === 'overwrite-and-export-env') { - ;(() => { - try { - const result = exportCredentialsToEnv({ - appId, - platform: 'android', - credentials: savedCredentials ?? {}, - targetPath: envExportTargetPath, - overwrite: true, - }) - if (cancelled) - return - if (result.kind === 'written') { - setEnvExportPath(result.path) - addLog(`✔ Overwrote ${result.path} with ${result.fieldCount} field${result.fieldCount === 1 ? '' : 's'}`) - } - setStep('build-complete') - } - catch (err) { - if (!cancelled) { - setEnvExportError(err instanceof Error ? err.message : String(err)) - setStep('build-complete') - } - } - })() - } - - if (step === 'requesting-build') { - ;(async () => { - try { - // CLI-flag key takes precedence over the saved one — same precedence - // the iOS path uses (build/onboarding/ui/app.tsx#624). Without this, - // `build init --platform android --apikey FOO` silently ignored FOO - // and fell back to whichever key was on disk. - let capgoKey: string | undefined = apikey - if (!capgoKey) - capgoKey = findSavedKeySilent() - if (!capgoKey) { - setBuildOutput(prev => [...prev, '⚠ No Capgo API key found.']) - setBuildOutput(prev => [...prev, 'Run `capgo login` first, then `capgo build request --platform android`.']) - setStep('build-complete') - return - } - const buildLogger: BuildLogger = { - info: (msg: string) => setBuildOutput(prev => [...prev, msg]), - error: (msg: string) => setBuildOutput(prev => [...prev, `✖ ${msg}`]), - warn: (msg: string) => setBuildOutput(prev => [...prev, `⚠ ${msg}`]), - success: (msg: string) => setBuildOutput(prev => [...prev, `✔ ${msg}`]), - buildLog: (msg: string) => setBuildOutput(prev => [...prev, ...sanitizeBuildLogLines(msg)]), - uploadProgress: (percent: number) => { - setBuildOutput((prev) => { - const idx = prev.findIndex(l => l.startsWith('Uploading:')) - const line = `Uploading: ${percent.toFixed(0)}%` - if (idx >= 0) { - const next = [...prev] - next[idx] = line - return next - } - return [...prev, line] - }) - }, - customMsg: async (kind: string, data: Record) => { - await handleCustomMsg( - kind, - data, - (line: string) => setBuildOutput(prev => [...prev, line]), - (line: string) => setBuildOutput(prev => [...prev, line]), - ) - }, - } - setBuildOutput([`Requesting build for ${appId} (android)...`]) - const result = await requestBuildInternal(appId, { - platform: 'android', - apikey: capgoKey, - // The Ink TUI owns the terminal — @clack/prompts inside - // requestBuildInternal would corrupt rendering. Caller-handled mode - // surfaces the captured log path via result.aiAnalysis and lets us - // render the AI flow with Ink-native components. - supaHost, - aiAnalysisMode: 'caller-handled', - }, true, buildLogger) - if (cancelled) - return - if (result.success) { - const url = `https://capgo.app/app/${appId}/builds` - setBuildUrl(url) - setBuildOutput(prev => [...prev, '', `✔ Build queued — ${url}`]) - // Only offer to push CI secrets AFTER we've successfully queued a - // build. If the build request failed (else branch) or we never had - // any credentials to push (entries empty), skip straight to exit. - if (ciSecretEntries.length > 0) { - setStep('detecting-ci-secrets') - return - } - } - else { - setBuildOutput(prev => [...prev, `⚠ ${result.error || 'unknown error'}`]) - // Offer AI-assisted diagnosis when logs were captured. The log file - // stays on disk until releaseCapturedLogs runs in 'build-complete'. - if (result.aiAnalysis?.ready && result.aiAnalysis.jobId) { - setAiJobId(result.aiAnalysis.jobId) - setStep('ai-analysis-prompt') - return - } - } - setStep('build-complete') - } - catch (err) { - if (!cancelled) { - setBuildOutput(prev => [...prev, `⚠ ${err instanceof Error ? err.message : String(err)}`]) - setBuildOutput(prev => [...prev, 'Your credentials are saved. Run `capgo build request --platform android` to try again.']) - setStep('build-complete') - } - } - })() - } - // AI analysis — entered only when requestBuildInternal returned with // aiAnalysis.ready=true. See iOS sibling for full notes. if (step === 'ai-analysis-running' && aiJobId) { @@ -2295,6 +1672,541 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } }, [step]) + // ─── Engine-driven auto-effect driver (Plan 3.2) ──────────────────────────── + // Route the android TUI's engine-driven 'auto' steps through the shared + // engine's `runAndroidEffect` instead of hand-rolled per-step useEffect bodies. + // `runAndroidEffect` already replicates the SAME automation these steps used to + // run inline (audit-verified). This driver wires the engine's deps to the + // TUI's existing helpers + log functions, applies the engine's transient and + // persisted progress back into the React render state, and re-emits the same + // step transition. Display logs are produced by the engine via + // deps.onLog/onStatus/onAuthUrl — wired to addLog/addSetupStatus/ + // setOauthStatusMessages — so the side-log + spinner-status UX is byte-for-byte + // unchanged. + // + // TUI-only auto steps (welcome, resume-prompt, ai-analysis-*, the file + // pickers) keep their bespoke effects in the useEffect above and are NOT routed + // here. Steps the engine does not yet implement (sa-json-validating, + // saving-credentials, gcp-projects-loading, the CI / env / workflow / build + // tail) also keep their TUI effects — routing them would lose TUI-specific work + // (telemetry, CI-secret entry building, the full GcpProject shape) the engine + // result can't reproduce. + useEffect(() => { + // Steps whose automation runAndroidEffect implements AND whose engine result + // cleanly reproduces every observable TUI behavior (logs + render state). + const failedStep = ENGINE_AUTO_FAILED_STEP[step] + if (!(step in ENGINE_AUTO_FAILED_STEP)) + return + + // Per-step one-shot guards — mirror the originals (oauthStartedRef, + // setupStartedRef, packageLoadedRef). The other engine-driven steps had no + // guard: the step is entered once, so a stray re-render re-run is a no-op. + if (step === 'google-sign-in-running') { + if (oauthStartedRef.current) + return + oauthStartedRef.current = true + } + if (step === 'gcp-setup-running') { + if (setupStartedRef.current) + return + setupStartedRef.current = true + } + if (step === 'android-package-select') { + if (packageLoadedRef.current) + return + packageLoadedRef.current = true + } + + let cancelled = false + // Abort wiring for cloud round-trips — mirrors the SA-validation cleanup + // pattern; aborts on step change / unmount / Ctrl+C. + const abort = new AbortController() + + void (async () => { + // OAuth client-config prep for google-sign-in-running. The original effect + // fetched the config, mirrored the client id into render state, and reset + // the streaming status list before opening the browser. + let oauthCfg: CapgoOAuthClientConfig | null = null + if (step === 'google-sign-in-running') { + try { + oauthCfg = await getCapgoConfig() + } + catch (err) { + if (!cancelled) + handleError(err, 'google-sign-in') + return + } + if (cancelled) + return + setOauthClientId(oauthCfg.clientId) + setOauthStatusMessages([]) + } + // gcp-setup-running resets its status stream before the engine streams in. + if (step === 'gcp-setup-running') + setSetupStatus([]) + + const deps: AndroidEffectDeps = { + onInternalLog: line => appendInternalLog(line), + // Keystore + generateKeystore, + listKeystoreAliases, + tryUnlockPrivateKey, + // Service-account validation + validateServiceAccountJson, + // Build-credentials persistence + updateSavedCredentials, + loadSavedCredentials, + // Onboarding-progress persistence + saveAndroidProgress, + loadAndroidProgress, + deleteAndroidProgress, + // File system + readFile, + copyFile, + // OAuth — driver pre-binds client config + scopes (config/scope policy + // stays in the driver, never reaches the core). + runOAuthFlow: callbacks => runOAuthFlow( + { + clientId: oauthCfg!.clientId, + clientSecret: oauthCfg!.clientSecret, + scopes: OAUTH_SCOPES_FOR_ONBOARDING, + }, + callbacks, + ), + fetchUserInfo, + getAccessToken: ensureAccessToken, + revokeToken, + // GCP + listProjects, + createProject: gcpCreateProject, + enableService, + ensureServiceAccount, + createServiceAccountKey, + // Google Play (engine deps expect Promise; play-api's + // inviteServiceAccount returns the invited user, which the original + // effect discarded — wrap to drop it so the types line up). + inviteServiceAccount: async (args) => { + await inviteServiceAccount(args) + }, + // Android project detection — driver pre-binds androidDir. + findAndroidApplicationIds: () => findAndroidApplicationIds(androidDir), + // Streaming callbacks — wire the engine's status/log/auth-url streams to + // the exact TUI sinks the original effects used, so every breadcrumb + + // spinner-status line is reproduced identically. + onLog: (message, color) => { + if (!cancelled) + addLog(message, color) + }, + onStatus: (message) => { + if (cancelled) + return + if (step === 'gcp-setup-running') + addSetupStatus(message) + else + setOauthStatusMessages(prev => [...prev, message]) + }, + onAuthUrl: (url) => { + if (!cancelled) + setOauthStatusMessages(prev => [...prev, `🌐 If the browser didn't open: ${url}`]) + }, + signal: abort.signal, + } + + try { + // Run the engine against the freshest persisted progress. Plan 3.1 made + // disk progress the source of truth for in-session sequencing; the prior + // input steps persist their fields before these auto steps run, so the + // loaded progress carries the same values the original effects read from + // React state. + const current = (await loadAndroidProgress(appId)) ?? emptyProgress(appId) + if (cancelled) + return + const result = await runAndroidEffect(step, current, deps) + if (cancelled) + return + + const t = result.transient + const np = result.progress + + // ── Apply transient runtime data to render state ────────────────────── + if (t?.detectedPackageIds !== undefined) + setDetectedPackageIds(t.detectedPackageIds) + if (t?.detectedAliases !== undefined) + setDetectedAliases(t.detectedAliases) + if (t?.accessToken !== undefined) + setAccessToken(t.accessToken) + + // ── Mirror engine-persisted progress into the render state that + // downstream TUI code (doSaveCredentials, renders) reads directly ────── + if (step === 'keystore-existing-detecting-alias') { + if (np.keystoreAlias) + setKeystoreAlias(np.keystoreAlias) + } + else if (step === 'keystore-generating') { + // _keystoreBase64 / keystoreReady are read straight from on-disk + // progress by doSaveCredentials now (Plan 3.3) — no React mirrors. + if (np.keystoreAlias) + setKeystoreAlias(np.keystoreAlias) + setRetryCount(0) + } + else if (step === 'google-sign-in-running') { + if (np._oauthRefreshToken) + setRefreshTokenState(np._oauthRefreshToken) + if (np.completedSteps.googleSignInComplete) + setGoogleSignIn(np.completedSteps.googleSignInComplete) + setRetryCount(0) + } + else if (step === 'gcp-setup-running') { + if (np.completedSteps.gcpProjectChosen) + setGcpProjectChoice(np.completedSteps.gcpProjectChosen) + if (np.completedSteps.serviceAccountProvisioned) + setServiceAccountProvisioned(np.completedSteps.serviceAccountProvisioned) + if (np.completedSteps.playInviteProvisioned) + setPlayInviteProvisioned(np.completedSteps.playInviteProvisioned) + // _serviceAccountKeyBase64 read from on-disk progress by doSaveCredentials now (Plan 3.3). + setRetryCount(0) + } + + // ── keystore-existing-detecting-alias wrong-password ───────────────── + // Reproduce the original special error UX (setError + retryStep + + // 'error') WITHOUT calling handleError (so retryCount is NOT bumped), + // instead of advancing to the engine's `next`. + if (t?.wrongPassword) { + setError('Store password was rejected by the keystore. Try again.') + setRetryStep('keystore-existing-store-password') + setStep('error') + return + } + + // ── Advance ────────────────────────────────────────────────────────── + // The engine returns an explicit `next` for these steps; fall back to + // the resume-derived step if it's ever absent. For android-package-select + // `next` is the same step (stay put after the pre-load), so only + // transition when it actually changes. + const nextStep = result.next ?? getAndroidResumeStep(np) + if (nextStep && nextStep !== step) + setStep(nextStep) + } + catch (err) { + if (cancelled) + return + // MissingScopesError on google-sign-in is handled INSIDE the engine + // (returns next: 'google-sign-in'); any other throw routes through the + // same retry/error UX the original effects used. android-package-select + // had no catch in the original (best-effort pre-load) — swallow there. + if (failedStep) + handleError(err, failedStep) + } + })() + + return () => { + cancelled = true + abort.abort() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]) + + // ─── Engine-driven post-save TAIL driver (ink-thin-wrapper) ───────────────── + // Delegate the post-save tail AUTO steps (TAIL_DRIVER_STEPS) to the shared + // engine's `runAndroidEffect`, which routes them into the platform-neutral tail + // module. The FLOW lives in the engine; the RENDERING stays ink. Unlike the + // early/mid driver above, the tail runs AFTER saving-credentials deletes + // progress.json, so the engine reads its inputs from a SYNTHETIC progress this + // driver builds from the in-memory React tail state, and threads the prior + // effects' transient back via deps.carried. The engine NEVER persists here. + useEffect(() => { + if (!TAIL_DRIVER_STEPS.has(step)) + return + + let cancelled = false + const abort = new AbortController() + + void (async () => { + // The Capgo API key the build/secret entries should reference — CLI flag + // takes precedence over the saved one (mirrors the bespoke tail's + // `apikey ?? findSavedKeySilent()` at saving-credentials/requesting-build). + const resolveCapgoKey = (): string | undefined => apikey ?? findSavedKeySilent() + + // Load the on-disk progress so the saving-credentials self-heal guard can + // re-validate it (the engine re-loads internally too via deps.loadProgress). + // For the later tail steps progress.json is already deleted, so this is null + // and the SYNTHETIC progress below carries the in-memory tail inputs instead. + const disk = await loadAndroidProgress(appId) + if (cancelled) + return + const base = disk ?? emptyProgress(appId) + // SYNTHETIC progress: overlay the in-memory React tail inputs the engine + // reads (setupMode / ciSecretTarget / selectedPackageManager / + // buildScriptChoice / envExportTargetPath) plus the keystorePasswordGenerated + // marker that gates the random-password backup hint (the bespoke held this in + // React `randomPasswordGenerated`, never persisted — thread it here so the + // hint still fires). selectedPackageManager carries the bespoke + // writing-workflow-file fallback (selectedPackageManager ?? detected pm) so the + // engine's own `?? 'npm'` fallback never diverges from the prior behaviour. + const tailProgress: AndroidOnboardingProgress = { + ...base, + setupMode, + ciSecretTarget, + selectedPackageManager: selectedPackageManager ?? normalizePackageManager(pm.pm), + buildScriptChoice, + envExportTargetPath, + keystorePasswordGenerated: randomPasswordGenerated, + } + + const deps: AndroidEffectDeps = { + onInternalLog: line => appendInternalLog(line), + // Keystore / provisioning deps — unused by the tail, present to satisfy + // the AndroidEffectDeps shape (the tail never calls them). + generateKeystore, + listKeystoreAliases, + tryUnlockPrivateKey, + validateServiceAccountJson, + updateSavedCredentials, + loadSavedCredentials, + saveAndroidProgress, + loadAndroidProgress, + deleteAndroidProgress, + readFile, + copyFile, + runOAuthFlow: async () => { throw new Error('not used in tail') }, + fetchUserInfo, + getAccessToken: ensureAccessToken, + revokeToken, + listProjects, + createProject: gcpCreateProject, + enableService, + ensureServiceAccount, + createServiceAccountKey, + inviteServiceAccount: async (args) => { + await inviteServiceAccount(args) + }, + findAndroidApplicationIds: () => findAndroidApplicationIds(androidDir), + + // ── tail helpers — pre-bind the resolved Capgo key into the entry builder + // so CAPGO_TOKEN is included (mirrors createCiSecretEntries(creds, capgoKey)). + createCiSecretEntries: creds => createCiSecretEntries(creds, resolveCapgoKey()), + detectCiSecretTargets, + getCiSecretRepoLabelAsync, + listExistingCiSecretKeysAsync, + uploadCiSecretsAsync, + exportCredentialsToEnv, + defaultExportPath, + generateWorkflow, + writeWorkflowFile, + // Thread the --supa-host gateway override into the engine-built build + // request: the tail engine composes the BuildRequestOptions itself + // ({ apikey, platform, aiAnalysisMode }), so the driver wraps the dep + // to inject supaHost (parity with main's bespoke requesting-build, + // which passed supaHost directly to requestBuildInternal). + requestBuildInternal: (id, options, silent, logger) => + requestBuildInternal(id, { ...options, supaHost }, silent, logger), + + // ── streaming / telemetry / preload sinks (forwarded into the shared tail) ── + // The rich streaming BuildLogger requesting-build forwards into + // requestBuildInternal (4th arg) — every build line streams into the + // FullscreenBuildOutput pane via setBuildOutput, byte-for-byte the bespoke + // logger (info/error/warn/success/buildLog sanitize/uploadProgress dedup/ + // customMsg → handleCustomMsg). Only requesting-build consumes it. + logger: { + info: (msg: string) => { if (!cancelled) setBuildOutput(prev => [...prev, msg]) }, + error: (msg: string) => { if (!cancelled) setBuildOutput(prev => [...prev, `✖ ${msg}`]) }, + warn: (msg: string) => { if (!cancelled) setBuildOutput(prev => [...prev, `⚠ ${msg}`]) }, + success: (msg: string) => { if (!cancelled) setBuildOutput(prev => [...prev, `✔ ${msg}`]) }, + buildLog: (msg: string) => { if (!cancelled) setBuildOutput(prev => [...prev, ...sanitizeBuildLogLines(msg)]) }, + uploadProgress: (percent: number) => { + if (cancelled) + return + setBuildOutput((prev) => { + const idx = prev.findIndex(l => l.startsWith('Uploading:')) + const line = `Uploading: ${percent.toFixed(0)}%` + if (idx >= 0) { + const next = [...prev] + next[idx] = line + return next + } + return [...prev, line] + }) + }, + customMsg: async (kind: string, data: Record) => { + await handleCustomMsg( + kind, + data, + (line: string) => { if (!cancelled) setBuildOutput(prev => [...prev, line]) }, + (line: string) => { if (!cancelled) setBuildOutput(prev => [...prev, line]) }, + ) + }, + }, + onBuildOutput: (line) => { + if (!cancelled) + setBuildOutput(prev => [...prev, line]) + }, + resolveApikey: resolveCapgoKey, + onCiSecretUploadProgress: (current, total, keyName) => { + if (!cancelled) + setCiSecretUploadProgress({ current, total, key: keyName }) + }, + onCiSecretCheckPhase: (phase) => { + if (!cancelled) + setCiSecretCheckPhase(phase) + }, + onCiSecretError: (message) => { + if (!cancelled) + setCiSecretError(message) + }, + getPackageScripts, + findProjectType, + findBuildCommandForProjectType, + trackWorkflowEvent: (event, options) => { + trackWorkflowEvent(event as BuildOnboardingWorkflowEvent, options as { decision?: BuildOnboardingWorkflowDecision }) + }, + + // ── carried transient (in-memory React tail state) ── + carried: { + // savedCredentials holds the exact 5-field string map the engine wrote + // at saving-credentials (no undefined values in practice); cast to the + // engine's Record carried shape. + savedCredentials: (savedCredentials ?? undefined) as Record | undefined, + ciSecretEntries, + ciSecretExistingKeys, + workflowIsNew: previewIsNew, + }, + + onLog: (message, color) => { + if (!cancelled) + addLog(message, color) + }, + signal: abort.signal, + } + + // requesting-build resets the build VIEWER to empty BEFORE the engine streams + // in, so the engine's appended header (onBuildOutput) reproduces the bespoke + // `setBuildOutput([header])` REPLACE — wiping a prior build's output on the AI + // retry re-entry instead of appending under it. + if (step === 'requesting-build') + setBuildOutput([]) + + try { + const result = await runAndroidEffect(step, tailProgress, deps) + if (cancelled) + return + + const t = result.transient + const np = result.progress + + // ── Mirror engine transient → render state ───────────────────────────── + if (t?.savedCredentials !== undefined) + setSavedCredentials(t.savedCredentials) + if (t?.ciSecretEntries !== undefined) + setCiSecretEntries(t.ciSecretEntries) + if (t?.ciSecretTargets !== undefined) + setCiSecretTargets(t.ciSecretTargets) + if (t?.ciSecretSetupAdvice !== undefined) + setCiSecretSetupAdvice(t.ciSecretSetupAdvice) + if (t?.ciSecretRepoLabel !== undefined) + setCiSecretRepoLabel(t.ciSecretRepoLabel) + if (t?.ciSecretExistingKeys !== undefined) + setCiSecretExistingKeys(t.ciSecretExistingKeys) + if (t?.ciSecretUploadSummary !== undefined) + setCiSecretUploadSummary(t.ciSecretUploadSummary) + if (t?.availableScripts !== undefined) + setAvailableScripts(t.availableScripts) + if (t?.recommendedScript !== undefined) + setRecommendedScript(t.recommendedScript) + // env-export results (exporting-env / overwrite-and-export-env). + if (t?.envExportPath !== undefined) + setEnvExportPath(t.envExportPath) + if (t?.envExportError !== undefined) + setEnvExportError(t.envExportError) + // writing-workflow-file: the written path (transient.workflowFilePath → + // the bespoke setWorkflowWrittenPath). The engine already emitted the + // Wrote/Overwrote log + workflow-file-written telemetry via onLog/ + // trackWorkflowEvent. + if (t?.workflowFilePath !== undefined) + setWorkflowWrittenPath(t.workflowFilePath) + // requesting-build: the queued build URL (transient.buildUrl) and the + // captured AI-analysis job id surfaced on a failed build (transient.aiJobId → + // the bespoke setAiJobId, which the ai-analysis-* ink sub-flow reads). + if (t?.buildUrl !== undefined) + setBuildUrl(t.buildUrl) + if (t?.aiJobId !== undefined) + setAiJobId(t.aiJobId) + // The chosen CI-secret target rides on the RETURNED progress (the engine + // sets it when detecting resolves a single target); mirror it into the + // React state the downstream choice/auto steps read. + if (np.ciSecretTarget !== undefined && np.ciSecretTarget !== null) + setCiSecretTarget(np.ciSecretTarget) + // exporting-env 'exists' carries the resolved export path forward on the + // RETURNED progress (the bespoke setEnvExportTargetPath) so + // overwrite-and-export-env can write to it. + if (np.envExportTargetPath !== undefined && np.envExportTargetPath !== envExportTargetPath) + setEnvExportTargetPath(np.envExportTargetPath) + // The upload progress bar is cleared by uploading-ci-secrets completing. + if (step === 'uploading-ci-secrets') + setCiSecretUploadProgress(null) + + // ── Advance ──────────────────────────────────────────────────────────── + // writing-workflow-file keeps the bespoke 150ms settle before advancing to + // build-complete (a driver concern — the engine returns next immediately). + if (result.next && result.next !== step) { + if (step === 'writing-workflow-file') { + const next = result.next + setTimeout(() => { + if (!cancelled) + setStep(next) + }, 150) + } + else { + setStep(result.next) + } + } + } + catch (err) { + if (cancelled) + return + // Step-aware error routing — match each bespoke tail handler's catch + // EXACTLY. The shared engine wraps checking-ci-secrets / exporting-env / + // overwrite-and-export-env / requesting-build internally (returns a failure + // route, never throws), but detecting-ci-secrets / uploading-ci-secrets / + // writing-workflow-file can still throw OUT of the engine, so the driver + // reproduces the bespoke recovery for those here. Credentials are already + // saved on every tail step, so only saving-credentials uses handleError. + const message = err instanceof Error ? err.message : String(err) + if (step === 'saving-credentials') { + handleError(err, 'saving-credentials') + } + else if (step === 'requesting-build') { + // The engine catches build-request throws internally (→ build-complete), + // so this is defensive parity with the bespoke catch (app.tsx ~L1430). + setBuildOutput(prev => [...prev, `⚠ ${message}`]) + setBuildOutput(prev => [...prev, `Your credentials are saved. Run \`capgo build request --platform android\` to try again.`]) + setStep('build-complete') + } + else if (step === 'exporting-env' || step === 'overwrite-and-export-env') { + setEnvExportError(message) + setStep('build-complete') + } + else if (step === 'writing-workflow-file') { + addLog(`⚠ Failed to write workflow file: ${message}`, 'yellow') + setTimeout(() => { + if (!cancelled) + setStep('build-complete') + }, 150) + } + else { + // detecting-ci-secrets / checking-ci-secrets / uploading-ci-secrets + setCiSecretError(message) + setStep('ci-secrets-failed') + } + } + })() + + return () => { + cancelled = true + abort.abort() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]) + // Route between the inline render and the scroll viewer based on the live // terminal size, BIDIRECTIONALLY (shrink → scroll, grow back → inline). See // iOS sibling + resolveAiResultRoute for the full rationale. @@ -2582,11 +2494,11 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } else if (choice === 'existing') { setKeystoreMethod('existing') - persistAndStep((p) => ({ ...p, keystoreMethod: 'existing' }), 'keystore-existing-path') + persistAndStep((p) => ({ ...p, keystoreMethod: 'existing' })) } else { setKeystoreMethod('generate') - persistAndStep((p) => ({ ...p, keystoreMethod: 'generate' }), 'keystore-new-alias') + persistAndStep((p) => ({ ...p, keystoreMethod: 'generate' })) } }} /> @@ -2615,7 +2527,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreExistingPath(abs) addLog(`✔ Keystore selected · ${abs}`) - persistAndStep((p) => ({ ...p, keystoreExistingPath: abs }), 'keystore-existing-store-password') + persistAndStep((p) => ({ ...p, keystoreExistingPath: abs })) }} /> )} @@ -2634,7 +2546,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreStorePassword(val) addLog('✔ Store password set') - persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-existing-detecting-alias') + persistAndStep((p) => ({ ...p, keystoreStorePassword: val })) }} /> )} @@ -2648,7 +2560,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir onSelect={(value) => { setKeystoreAlias(value) addLog(`✔ Alias selected · ${value}`) - persistAndStep((p) => ({ ...p, keystoreAlias: value }), 'keystore-existing-key-password') + persistAndStep((p) => ({ ...p, keystoreAlias: value })) }} /> )} @@ -2660,7 +2572,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) => ({ ...p, keystoreAlias: alias })) }} /> )} @@ -2682,8 +2594,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir alias: keystoreAlias || RELEASE_ALIAS_DEFAULT, isGenerated: false, } - setKeystoreBase64(base64) - setKeystoreReady(ready) + // _keystoreBase64 / keystoreReady persisted below — no React mirrors (Plan 3.3). await persist((p) => ({ ...p, keystoreKeyPassword: keyPw, @@ -2718,7 +2629,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) => ({ ...p, keystoreAlias: alias })) }} /> )} @@ -2733,7 +2644,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setKeystoreKeyPassword(pw) setRandomPasswordGenerated(true) addLog('✔ Store + key passwords generated') - persistAndStep((p) => ({ ...p, keystoreStorePassword: pw, keystoreKeyPassword: pw }), 'keystore-new-cn') + persistAndStep((p) => ({ ...p, keystoreStorePassword: pw, keystoreKeyPassword: pw })) } else { setStep('keystore-new-store-password') @@ -2754,6 +2665,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setKeystoreStorePassword(val) addLog('✔ Store password set') + // TUI-only target: the engine derives keystore-new-cn here (it does + // not model a separate manual key-password input), so we keep the + // explicit step to land on the dedicated key-password screen. persistAndStep((p) => ({ ...p, keystoreStorePassword: val }), 'keystore-new-key-password') }} /> @@ -2766,7 +2680,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) => ({ ...p, keystoreKeyPassword: keyPw })) }} /> )} @@ -2779,6 +2693,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const cn = val.trim() || appId setKeystoreCommonName(cn) addLog(`✔ Common name · ${cn}`) + // TUI-only target: keystore-generating is the effect screen the + // engine cannot derive (the keystore is not yet fully valid — the + // _keystoreBase64 / keystoreReady writes happen in the build effect). persistAndStep((p) => ({ ...p, keystoreCommonName: cn }), 'keystore-generating') }} /> @@ -2795,23 +2712,17 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir if (selectFiredRef.current) return selectFiredRef.current = true - setServiceAccountMethod(method) + // serviceAccountMethod has no React mirror (Plan 3.4); persisted below. trackAction('android_sa_method_selected', { method }) if (method === 'existing') { // Import path needs the package name first so validation can // probe edits.insert(packageName). The package-select step is // shared with the OAuth path and routes back here based on // serviceAccountMethod. - persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'existing' }), - 'android-package-select', - ) + persistAndStep((p) => ({ ...p, serviceAccountMethod: 'existing' })) } else { - persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'generate' }), - 'google-sign-in', - ) + persistAndStep((p) => ({ ...p, serviceAccountMethod: 'generate' })) } }} /> @@ -2849,10 +2760,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setServiceAccountJsonPath(abs) addLog(`✔ Service account JSON · ${abs}`) - persistAndStep( - (p) => ({ ...p, serviceAccountJsonPath: abs }), - 'sa-json-validating', - ) + persistAndStep((p) => ({ ...p, serviceAccountJsonPath: abs })) }} /> )} @@ -2887,10 +2795,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setServiceAccountJsonPath('') setSaValidationResult(null) setSaJsonPathMode('choose') - persistAndStep( - (p) => ({ ...p, serviceAccountJsonPath: undefined }), - 'sa-json-existing-path', - ) + persistAndStep((p) => ({ ...p, serviceAccountJsonPath: undefined })) return } if (value === 'save-anyway') { @@ -2901,7 +2806,7 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir throw new Error('No service account JSON path on record.') const bytes = await readFile(serviceAccountJsonPath) const base64 = bytes.toString('base64') - setServiceAccountKeyBase64(base64) + // _serviceAccountKeyBase64 persisted below — no React mirror (Plan 3.3). await persist((p) => ({ ...p, _serviceAccountKeyBase64: base64, @@ -2918,12 +2823,9 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } // oauth — fall back to the OAuth provisioning path. trackAction('android_sa_validation_recovery_selected', { recovery_action: 'fallback_oauth' }) - setServiceAccountMethod('generate') + // serviceAccountMethod has no React mirror (Plan 3.4); persisted below. setSaValidationResult(null) - persistAndStep( - (p) => ({ ...p, serviceAccountMethod: 'generate' }), - 'google-sign-in', - ) + persistAndStep((p) => ({ ...p, serviceAccountMethod: 'generate' })) }} /> )} @@ -3005,13 +2907,10 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const choice: PlayDeveloperAccountChoice = { developerId: id } setPlayAccountChoice(choice) addLog(`✔ Play Developer account — ${id}`) - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, playAccountChosen: choice }, - }), - 'gcp-projects-loading', - ) + persistAndStep((p) => ({ + ...p, + completedSteps: { ...p.completedSteps, playAccountChosen: choice }, + })) }} /> )} @@ -3048,13 +2947,10 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setGcpProjectChoice(choice) addLog(`✔ GCP project — ${chosen.name}`) - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, gcpProjectChosen: choice }, - }), - 'android-package-select', - ) + persistAndStep((p) => ({ + ...p, + completedSteps: { ...p.completedSteps, gcpProjectChosen: choice }, + })) }} /> )} @@ -3076,15 +2972,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir setGcpProjectChoice(choice) setNewProjectDisplayName(displayName) addLog(`✔ GCP project (new) — ${displayName} / ${projectId}`) - persistAndStep( - (p) => ({ - ...p, - pendingNewProjectId: projectId, - pendingNewProjectDisplayName: displayName, - completedSteps: { ...p.completedSteps, gcpProjectChosen: choice }, - }), - 'android-package-select', - ) + persistAndStep((p) => ({ + ...p, + pendingNewProjectId: projectId, + pendingNewProjectDisplayName: displayName, + completedSteps: { ...p.completedSteps, gcpProjectChosen: choice }, + })) }} /> )} @@ -3121,15 +3014,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setAndroidPackageChoice(choice) addLog(`✔ Android package — ${value}`) - const nextStep: AndroidOnboardingStep - = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), - nextStep, - ) + // Next step (sa-json-existing-path for the import path, else + // gcp-setup-running) is derived by the engine from serviceAccountMethod. + persistAndStep((p) => ({ + ...p, + completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, + })) }} onSubmitManual={(val) => { const name = val.trim() @@ -3145,15 +3035,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir } setAndroidPackageChoice(choice) addLog(`✔ Android package — ${name}`) - const nextStep: AndroidOnboardingStep - = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), - nextStep, - ) + // Next step (sa-json-existing-path for the import path, else + // gcp-setup-running) is derived by the engine from serviceAccountMethod. + persistAndStep((p) => ({ + ...p, + completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, + })) }} /> )} @@ -3192,6 +3079,14 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir })), { label: 'Skip', value: 'skip' }, ]} + // [DIVERGE — driver-routed] Although this step IS a tail input (it + // records ciSecretTarget), every option diverges from the resume router: + // the provider FAN-OUT (github → ask-github-actions-setup, gitlab → + // ask-ci-secrets) is an in-session branch the engine collapses onto the + // read-only checking-ci-secrets (any chosen target), and skip/null → + // re-detection, not build-complete. The fan-out is effect-routed in the + // engine (detecting-ci-secrets), so keep these explicit. See + // test/test-android-tail-routing.mjs (ci-secrets-target-select cases). onChange={(value) => { if (value === 'skip') { setStep('build-complete') @@ -3248,13 +3143,17 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir { label: '❌ No', value: 'no' }, ]} onChange={(value) => { - if (value === 'no') { - setSetupMode('declined') - setStep('ask-export-env') - return - } - setSetupMode(value as 'with-workflow' | 'secrets-only') - setStep('checking-ci-secrets') + // Option value 'no' maps to the persisted setupMode 'declined'. + const mode = value === 'no' ? 'declined' : (value as 'with-workflow' | 'secrets-only') + setSetupMode(mode) + // Engine-derived [MATCH]: with the GitHub target chosen (post-build, + // pre-upload), with-workflow/secrets-only resume to checking-ci-secrets + // and declined resumes to ask-export-env. + setStep(tailEngineNext( + 'ask-github-actions-setup', + { step: 'ask-github-actions-setup', value: mode }, + { buildRequested: true }, + )) }} /> @@ -3282,10 +3181,20 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir ]} onChange={(value) => { if (value === 'yes') { - setEnvExportTargetPath(defaultExportPath(appId, 'android')) - setStep('exporting-env') + const exportPath = defaultExportPath(appId, 'android') + setEnvExportTargetPath(exportPath) + // Engine-derived [MATCH]: declined GH Actions + a chosen export + // path resumes to the (overwrite-safe) exporting-env write effect. + setStep(tailEngineNext( + 'ask-export-env', + { step: 'ask-export-env', value: 'yes', envExportTargetPath: exportPath }, + { buildRequested: true }, + )) return } + // [DIVERGE] 'no' records no field — there is no "export declined" + // marker, so the resume router would re-show this prompt. The + // in-session decline ends the tail, so keep it driver-routed. setStep('build-complete') }} /> @@ -3341,8 +3250,15 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir { label: `📦 yarn${detected === 'yarn' ? ' (recommended — matches your lockfile)' : ''}`, value: 'yarn' }, ]} onChange={(value) => { - setSelectedPackageManager(value as PackageManager) - setStep('pick-build-script') + const selected = value as PackageManager + setSelectedPackageManager(selected) + // Engine-derived [MATCH]: post-upload with-workflow + a chosen PM + // (no build-script yet) resumes to pick-build-script. + setStep(tailEngineNext( + 'pick-package-manager', + { step: 'pick-package-manager', selectedPackageManager: selected }, + { buildRequested: true, ciSecretsUploaded: true }, + )) }} /> @@ -3362,6 +3278,12 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir s. + +const OPTIONS_CI_SECRETS_SETUP: TailStepOption[] = [ + { value: 'retry', label: 'I installed and logged in, check again' }, + { value: 'skip', label: 'Skip upload' }, +] + +const OPTIONS_CONFIRM_CI_SECRET_OVERWRITE: TailStepOption[] = [ + { value: 'replace', label: 'Replace existing env vars' }, + { value: 'skip', label: 'Skip upload' }, +] + +const OPTIONS_CI_SECRETS_FAILED: TailStepOption[] = [ + { value: 'retry', label: 'Try upload again' }, + { value: 'continue', label: 'Continue without upload' }, +] + +const OPTIONS_ASK_GITHUB_ACTIONS_SETUP: TailStepOption[] = [ + { value: 'with-workflow', label: '🚀 Yes — set the secrets AND create a workflow file' }, + { value: 'secrets-only', label: '🔒 Yes — set ONLY the secrets' }, + { value: 'no', label: '❌ No' }, +] + +const OPTIONS_CONFIRM_ENV_EXPORT_OVERWRITE: TailStepOption[] = [ + { value: 'replace', label: '✏️ Replace it' }, + { value: 'skip', label: '🛑 Skip — keep the existing file' }, +] + +const OPTIONS_PICK_PACKAGE_MANAGER: TailStepOption[] = [ + { value: 'bun', label: '📦 bun' }, + { value: 'npm', label: '📦 npm' }, + { value: 'pnpm', label: '📦 pnpm' }, + { value: 'yarn', label: '📦 yarn' }, +] + +const OPTIONS_PREVIEW_WORKFLOW_FILE: TailStepOption[] = [ + { value: 'write', label: '✏️ Write file' }, + { value: 'view', label: '👀 Show proposed file diff' }, + { value: 'cancel', label: '❌ Do not write file' }, +] + +const OPTIONS_VIEW_WORKFLOW_DIFF: TailStepOption[] = [ + { value: 'close', label: 'Close diff' }, +] + +const OPTIONS_ASK_BUILD: TailStepOption[] = [ + { value: 'yes', label: '🚀 Yes, request a build' }, + { value: 'no', label: '⏭ Not now' }, +] + +const OPTIONS_PICK_BUILD_SCRIPT_FALLBACK: TailStepOption[] = [ + { value: '__custom__', label: 'Type a custom command…' }, + { value: '__skip__', label: 'Skip build step (my app is raw HTML)' }, +] + +// KIND for each tail step — copied verbatim from android KIND_TABLE. +const TAIL_KIND: Record = { + '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', +} + +// ─── tailViewForStep ────────────────────────────────────────────────────────── + +/** + * Pure: a UI-framework-neutral description of a tail step. Mirrors the matching + *