UNPKG

@capgo/cli

Version:
525 lines (524 loc) 23.9 kB
import type { Buffer } from 'node:buffer'; import type { AndroidOnboardingProgress, AndroidOnboardingStep } from './types.js'; import type { KeystoreOptions, KeystoreResult, ListAliasesResult, ProbeKeyPasswordResult } from './keystore.js'; import type { ValidateOptions, ValidationResult } from './service-account-validation.js'; import type { GcpProject, GcpServiceAccount, GcpServiceAccountKey } from './gcp-api.js'; import type { GoogleOAuthTokens, GoogleUserInfo, PendingOAuthSession, RunOAuthFlowOptions } from './oauth-google.js'; import type { BuildCredentials } from '../../../schemas/build.js'; import type { BuildLogger, BuildRequestOptions, BuildRequestResult } from '../../request.js'; import type { AsyncCommandRunner, CiSecretDiscovery, CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget, CommandRunner } from '../ci-secrets.js'; import type { EnvExportOpts, EnvExportResult } from '../env-export.js'; import type { BuildScriptChoice, GeneratedWorkflow, PackageManager, WorkflowGeneratorOpts } from '../workflow-generator.js'; import type { WorkflowWriteOptions, WorkflowWriteResult } from '../workflow-writer.js'; /** * Pure helper: given persisted progress, validated OAuth tokens and the user's * profile, return a NEW progress object (immutable spread) with: * - `_oauthRefreshToken` set to `tokens.refreshToken` * - `completedSteps.googleSignInComplete` set to `{ email, googleSubject, scope }` * * This is the single canonical place where Google sign-in state is written to * progress — shared by the Ink core effect (`google-sign-in-running`) and the * MCP bridge so both produce identical progress objects. */ export declare function applyGoogleSignIn(progress: AndroidOnboardingProgress, tokens: GoogleOAuthTokens, info: GoogleUserInfo): AndroidOnboardingProgress; /** * Apply a BROKER (access-token) Google sign-in. The MCP path receives a short-lived access token from the * Capgo OAuth broker and CANNOT refresh it (the token is issued to the broker's confidential Web client), so * it stores the access token + its expiry and re-signs-in on expiry — no `_oauthRefreshToken`. Mirrors * applyGoogleSignIn for the refresh-token (TUI loopback) path. */ export declare function applyGoogleSignInBroker(progress: AndroidOnboardingProgress, accessToken: string, expiresAt: number | null, info: GoogleUserInfo): AndroidOnboardingProgress; export type AndroidStepKind = 'auto' | 'input' | 'choice' | 'done' | 'error'; export interface AndroidStepOption { value: string; label?: string; note?: string; } export interface AndroidStepView { step: AndroidOnboardingStep; kind: AndroidStepKind; title?: string; prompt?: string; collect?: string[]; options?: AndroidStepOption[]; message?: string; } export interface AndroidStepCtx { appId: string; detectedPackageIds?: string[]; gcpProjects?: { projectId: string; name: string; projectNumber?: string; }[]; detectedAliases?: string[]; saValidation?: { ok: false; kind: string; message: string; } | { ok: true; }; /** * Task 3 — keystore-existing-key-password prompt boundary. * Set to true in AndroidEffectResult.transient when the auto-probe could not * resolve the key password and the driver should show the manual input. * After the user submits, applyAndroidInput records keystoreKeyPassword and * re-running the effect finds it set and completes the phase. */ needsKeyPasswordPrompt?: boolean; /** * Task 4 — fresh access token returned from google-sign-in-running so the * driver can seed its token cache and avoid an immediate refresh on the next * step. If the driver ignores it, deps.getAccessToken() will mint one — * behaviorally identical. */ accessToken?: string; /** * keystore-existing-detecting-alias wrong-password signal. * Set to true in AndroidEffectResult.transient when listKeystoreAliases returns * { ok: false, reason: 'wrong-password' }. The driver (app.tsx) maps this to * the original UX: setError + setRetryStep('keystore-existing-store-password') * + setStep('error') WITHOUT calling handleError (no retryCount bump). */ wrongPassword?: boolean; /** CI-secret entries built at saving-credentials (key/value/masked). */ ciSecretEntries?: CiSecretEntry[]; /** * Full saved-credential map written at saving-credentials (the 5 build-cred * fields, NO CAPGO_TOKEN). The Ink TUI holds the same in its `savedCredentials` * React state and the env-export effects write it verbatim. Transient only — * never persisted to progress.json (it carries the raw keystore/SA secrets). */ savedCredentials?: Record<string, string>; /** 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; /** Detected package manager from the project's lockfile (pick-package-manager). */ detectedPackageManager?: string; /** All scripts from package.json (pick-build-script picker). */ availableScripts?: Record<string, string>; /** 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; } export declare const KIND_TABLE: Record<AndroidOnboardingStep, AndroidStepKind>; /** * 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 declare function androidViewForStep(step: AndroidOnboardingStep, progress: AndroidOnboardingProgress | null, ctx: AndroidStepCtx): AndroidStepView; export type AndroidInput = { step: 'credentials-exist'; value: 'backup' | 'cancel'; } | { step: 'keystore-method-select'; value: 'existing' | 'generate' | 'learn'; } | { step: 'keystore-existing-path'; path: string; } | { step: 'keystore-existing-store-password'; password: string; } | { step: 'keystore-existing-alias-select'; alias: string; } | { step: 'keystore-existing-alias'; alias: string; } | { step: 'keystore-existing-key-password'; password: string; } | { step: 'keystore-new-alias'; alias: string; } | { step: 'keystore-new-password-method'; value: 'random' | 'manual'; } | { step: 'keystore-new-store-password'; password: string; } | { step: 'keystore-new-key-password'; password: string; } | { step: 'keystore-new-cn'; cn: string; } | { step: 'service-account-method-select'; value: 'generate' | 'existing'; } | { step: 'sa-json-existing-path'; path: string; } | { step: 'sa-json-validation-failed'; value: 'retry' | 'oauth'; } | { step: 'sa-json-validation-failed'; value: 'save-anyway'; serviceAccountKeyBase64: string; } | { step: 'play-developer-id-input'; rawDeveloperIdOrUrl: string; } | { step: 'gcp-projects-select'; gcpProject: { projectId: string; name: string; projectNumber?: string; }; } | { step: 'gcp-project-create-name'; displayName: string; } | { step: 'android-package-select'; packageName: string; source: 'gradle' | 'capacitor-config' | 'user-input'; serviceAccountMethod: 'generate' | 'existing'; } | { step: 'ci-secrets-setup'; value: 'retry' | 'skip'; } | { step: 'ci-secrets-target-select'; ciSecretTarget: CiSecretTarget | null; } | { step: 'ask-ci-secrets'; value: 'yes' | 'no'; } | { step: 'confirm-ci-secret-overwrite'; value: 'replace' | 'skip'; } | { step: 'ci-secrets-failed'; value: 'retry' | 'continue'; } | { step: 'ask-github-actions-setup'; value: 'with-workflow' | 'secrets-only' | 'declined'; } | { step: 'confirm-secrets-push'; value: 'confirm' | 'cancel'; } | { step: 'ask-export-env'; value: 'no'; } | { step: 'ask-export-env'; value: 'yes'; envExportTargetPath: string; } | { step: 'confirm-env-export-overwrite'; value: 'replace' | 'skip'; } | { step: 'pick-package-manager'; selectedPackageManager: PackageManager; } | { step: 'pick-build-script'; value: '__custom__'; } | { step: 'pick-build-script'; buildScriptChoice: BuildScriptChoice; } | { step: 'pick-build-script-custom'; command: string; } | { step: 'preview-workflow-file'; value: 'write' | 'view' | 'cancel'; } | { step: 'view-workflow-diff'; value: 'close'; } | { step: 'ask-build'; value: 'yes' | 'no'; }; export declare function applyAndroidInput(step: AndroidOnboardingStep, progress: AndroidOnboardingProgress, input: AndroidInput): AndroidOnboardingProgress; export interface AndroidEffectDeps { generateKeystore: (opts: KeystoreOptions) => KeystoreResult; listKeystoreAliases: (bytes: Uint8Array, password: string) => ListAliasesResult; tryUnlockPrivateKey: (bytes: Uint8Array, password: string) => ProbeKeyPasswordResult; validateServiceAccountJson: (opts: ValidateOptions) => Promise<ValidationResult>; updateSavedCredentials: (appId: string, platform: 'ios' | 'android', credentials: Record<string, string>) => Promise<void>; loadSavedCredentials: (appId: string) => Promise<unknown>; saveAndroidProgress: (appId: string, progress: AndroidOnboardingProgress) => Promise<void>; loadAndroidProgress: (appId: string) => Promise<AndroidOnboardingProgress | null>; deleteAndroidProgress: (appId: string) => Promise<void>; readFile: (path: string) => Promise<Buffer>; copyFile: (src: string, dest: string) => Promise<void>; /** * 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<RunOAuthFlowOptions, 'onAuthUrl' | 'onStatus'>) => Promise<GoogleOAuthTokens>; /** * 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<RunOAuthFlowOptions, 'onAuthUrl' | 'onStatus'>) => Promise<PendingOAuthSession>; /** * Open a URL in the user's default browser — used by the MCP broker sign-in to (optionally) open the * sign-in link for the user. Best-effort: the engine falls back to showing the link if this throws or is * absent. Optional (the Ink driver never uses it). */ openBrowser?: (url: string) => Promise<void>; /** Fetch the signed-in user's profile (email, sub). */ fetchUserInfo: (accessToken: string) => Promise<GoogleUserInfo>; /** * 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<string>; /** * Revoke a Google OAuth refresh token. Best-effort — the core swallows * failures (non-fatal; the token expires on its own). */ revokeToken: (refreshToken: string) => Promise<void>; /** * List GCP projects the user has access to. * Mirrors gcp-api.ts: listProjects(accessToken). */ listProjects: (accessToken: string) => Promise<GcpProject[]>; /** * 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<GcpProject>; /** * Enable an API on a project (idempotent). * Mirrors gcp-api.ts: enableService(accessToken, projectId, serviceName). */ enableService: (accessToken: string, projectId: string, serviceName: string) => Promise<void>; /** * 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<GcpServiceAccountKey>; /** * 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<void>; /** * 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<string[]>; /** * Build the CI-secret entries (key/value/masked) from the saved credentials. * Mirrors ci-secrets.ts: createCiSecretEntries(credentials, apiKey?). */ createCiSecretEntries?: (credentials: Partial<BuildCredentials>, 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<string | null>; /** * 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<string[]>; /** * 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<void>; /** * 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; /** * Lockfile-based package-manager detection (pure, read-only). Drives the * pick-package-manager 'recommended — matches your lockfile' note. * Mirrors @capgo/find-package-manager: findPackageManagerType(cwd, 'npm'). */ detectPackageManager?: () => 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<BuildRequestResult>; /** * 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<string, string>; /** Detects the web-framework project type (best-effort; may resolve null). */ findProjectType?: (options?: { quiet?: boolean; }) => Promise<string | null>; /** Maps a detected project type to its recommended build script name. */ findBuildCommandForProjectType?: (projectType: string) => Promise<string | null>; /** * 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<string, string>; /** 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; }; 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; } 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<AndroidStepCtx>; } export declare function runAndroidEffect(step: AndroidOnboardingStep, progress: AndroidOnboardingProgress, deps: AndroidEffectDeps): Promise<AndroidEffectResult>;