@capgo/cli
Version:
A CLI to upload to capgo servers
525 lines (524 loc) • 23.9 kB
TypeScript
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>;