@capgo/cli
Version:
A CLI to upload to capgo servers
673 lines (672 loc) • 37.8 kB
TypeScript
import type { Buffer } from 'node:buffer';
import type { AscApp, AscDistributionCert, AscProfileSummary } from '../apple-api.js';
import type { ApiKeyData, CertificateData, EnrichedIdentityAvailability, OnboardingProgress, OnboardingStep, ProfileData } from '../types.js';
import type { AsyncCommandRunner, CiSecretDiscovery, CiSecretEntry, CiSecretTarget, CommandRunner } from '../ci-secrets.js';
import type { DiscoveredProfile, ExportedP12, IdentityProfileMatch, SigningIdentity } from '../macos-signing.js';
import type { MobileprovisionDetail } from '../../mobileprovision-parser.js';
import type { BuildCredentials } from '../../../schemas/build.js';
import type { BuildLogger, BuildRequestOptions, BuildRequestResult } from '../../request.js';
import type { EnvExportOpts, EnvExportResult } from '../env-export.js';
import type { GeneratedWorkflow, WorkflowGeneratorOpts } from '../workflow-generator.js';
import type { WorkflowWriteOptions, WorkflowWriteResult } from '../workflow-writer.js';
import type { TailTransient } from '../tail/flow.js';
import type { AppVerifyResult, AscAppLike, GatePath } from '../app-verification.js';
import type { DetectedBundleIds } from '../bundle-id-detector.js';
/**
* Stable reason an identity has no usable matching profile. Drives the
* `import-no-match-recovery` menu variant. (Mirrors the `noMatchReason` enum.)
*/
export type IosNoMatchReason = 'apple-no-cert-match' | 'apple-no-profiles-linked' | 'apple-bundle-mismatch' | 'apple-distribution-mismatch' | 'apple-other' | 'no-profile-on-disk';
/**
* A duplicate Capgo provisioning profile (creating-profile / import-create).
* Matches the `{ id, name, profileType }` triple returned by apple-api's
* findCapgoProfiles() and carried on DuplicateProfileError.profiles. Derived
* from the real AscProfileSummary so it tracks any future field additions.
*/
export type IosDuplicateProfile = Pick<AscProfileSummary, 'id' | 'name' | 'profileType'>;
export type IosStepKind = 'auto' | 'input' | 'choice' | 'done' | 'error';
export interface IosStepOption {
value: string;
label?: string;
note?: string;
}
export interface IosStepView {
step: OnboardingStep;
kind: IosStepKind;
title?: string;
prompt?: string;
collect?: string[];
options?: IosStepOption[];
message?: string;
}
/**
* Per-step runtime context the driver supplies to the view builder AND threads
* back through `IosEffectResult.transient` between effects. EVERY field is
* OPTIONAL so a caller that only passes `{ appId }` still gets a usable view.
*
* This is the iOS "ephemeral inventory" — driver-held transient state that is
* NEVER persisted to progress.json (it carries Apple-side selections + raw
* cert/profile/keychain payloads). The total resume function (getIosResumeStep)
* NEVER produces a step that depends on these — on resume the driver re-runs the
* silent inventory (import-scanning) and re-renders the picker. See the audit's
* "Ephemeral inventory" section for the producer/consumer map.
*/
export interface IosStepCtx {
appId?: string;
/** Selected signing identity (import-pick-identity). REQUIRED by import-exporting. */
chosenIdentity?: SigningIdentity;
/** Selected provisioning profile (import-pick-profile). REQUIRED by import-exporting. */
chosenProfile?: DiscoveredProfile;
/** Discovery result list from import-scanning (identities + on-disk profiles). */
importMatches?: IdentityProfileMatch[];
/** Scanned on-disk profiles (paired with importMatches). */
importProfiles?: DiscoveredProfile[];
/** Per-identity Apple-side availability (import-validating-all-certs). */
identityAvailability?: Record<string, EnrichedIdentityAvailability>;
/** Per-identity prefetched Apple profiles (parallel prefetch after validation). */
profilePrefetch?: Record<string, DiscoveredProfile[]>;
/** Apple cert resource id for the chosen identity (import-checking-apple-cert). */
_appleCertIdForChosen?: string;
/** Why the chosen identity has no usable profile — drives the recovery menu. */
noMatchReason?: IosNoMatchReason;
/** Duplicate Capgo profiles (creating-profile / import-create-profile-only). */
duplicateProfiles?: IosDuplicateProfile[];
/** Existing Apple certs offered for revocation when the cert limit is hit. */
existingCerts?: AscDistributionCert[];
/** The user's revoke selection (cert-limit-prompt → revoking-certificate). */
certToRevoke?: AscDistributionCert;
/**
* Whether the host can show a native file picker — gates the
* import-no-match-recovery / import-portal-explanation "use a .mobileprovision
* from disk" option (the TUI's canUseFilePicker(), app.tsx:3570/3733). The
* DRIVER threads the host capability here; the view defaults it to true (the
* macOS-first onboarding target) so a caller that only passes { appId } still
* gets the file-picker recovery option.
*/
canUseFilePicker?: boolean;
/** Resolved certificate data (creating-profile create-new / import-exporting). */
certData?: CertificateData;
/** Resolved profile data (creating-profile create-new / import-exporting). */
profileData?: ProfileData;
/** Apple team id resolved alongside certData/profileData. */
teamId?: string;
/** Keychain export password (import-exporting). Transient only. */
importedP12Password?: string;
/** Buffer of .p8 file content during validation (only the PATH is persisted). */
p8Content?: Buffer;
/**
* True once the p8-method-select file-picker effect has opened the native
* dialog this drive — the engine returns it in transient and the driver
* threads it back as `deps.carried.pickerOpened` so a re-render does NOT
* re-open the picker. Mirrors the TUI's `pickerOpenedRef` guard.
*/
pickerOpened?: boolean;
/**
* True once the import-provide-profile-path .mobileprovision picker has opened
* the native dialog this drive — returned in transient and threaded back as
* `deps.carried.profilePickerOpened` so a re-render does NOT re-open the
* picker. SEPARATE from `pickerOpened` (the .p8 picker). Mirrors the TUI's
* `mobileprovisionPickerOpenedRef` guard (app.tsx:1689).
*/
profilePickerOpened?: boolean;
/** Verified key id + issuer id (mirror of completedSteps.apiKeyVerified). */
apiKey?: ApiKeyData;
/** ASC apps fetched by the verify-app effect (picker source + Path B re-poll). */
verifyApps?: AscApp[];
/** Registered Developer-portal bundle ids (diagnostic — sharpens Path B wording). */
verifyRegisteredIds?: string[];
/** The authoritative Release build id, re-detected FRESH from disk ('' = unresolved). */
verifyReleaseBundleId?: string;
/** The Debug-config bundle id when it differs from Release (else ''). */
verifyDebugBundleId?: string;
/** True when Debug + Release literal ids both exist AND differ (awareness note + telemetry). */
verifyDebugReleaseDiffer?: boolean;
/**
* The verify-app classification (the pure classifyAppVerification result),
* widened with the two pass-through outcomes ('fetch-failed' /
* 'no-release-config') so the driver's Result telemetry mirrors the TUI's.
* Present once the initial fetch has run — its absence is what makes
* iosViewForStep render verify-app as an AUTO effect instead of the gate.
*/
verifyResult?: AppVerifyResult | 'fetch-failed' | 'no-release-config';
/** Which gate path the user is on (null = the picker). */
verifyPath?: GatePath | null;
/** The existing app picked in Path A (its bundleId is the target to match). */
verifyChosenApp?: AscAppLike | null;
/** 1-based count of blocked Continue attempts (drives the escalating warning). */
verifyAttempt?: number;
/** Path B: ask before re-opening the browser after a blocked re-poll. */
verifyAskReopen?: boolean;
/** Where verify-app routes on pass/pass-through (set by verifying-key on import). */
pendingVerifyNext?: OnboardingStep;
/** Human-readable error message the error view renders (failing step's message). */
error?: string;
/** The step to re-run when the user picks "Try again" (absent = no retry offered). */
retryStep?: OnboardingStep;
ciSecretEntries?: TailTransient['ciSecretEntries'];
savedCredentials?: TailTransient['savedCredentials'];
ciSecretTargets?: TailTransient['ciSecretTargets'];
ciSecretSetupAdvice?: TailTransient['ciSecretSetupAdvice'];
ciSecretRepoLabel?: TailTransient['ciSecretRepoLabel'];
ciSecretExistingKeys?: TailTransient['ciSecretExistingKeys'];
ciSecretUploadSummary?: TailTransient['ciSecretUploadSummary'];
envExportPath?: TailTransient['envExportPath'];
workflowFilePath?: TailTransient['workflowFilePath'];
buildUrl?: TailTransient['buildUrl'];
buildOutput?: TailTransient['buildOutput'];
aiJobId?: TailTransient['aiJobId'];
availableScripts?: TailTransient['availableScripts'];
recommendedScript?: TailTransient['recommendedScript'];
envExportError?: TailTransient['envExportError'];
ciSecretError?: TailTransient['ciSecretError'];
}
/**
* Async dependencies the iOS effects need (Apple API client, CSR + keychain
* export, mobileprovision parsing, persistence, the shared tail helpers, and
* status/log callbacks). EVERY helper is OPTIONAL and ADDITIVE so a driver can
* inject only what the path it drives needs and the skeleton's stubs keep
* type-checking. Data types are the REAL exports from apple-api / macos-signing /
* mobileprovision-parser / csr; only the call-shape envelopes are engine-local.
*/
export interface IosEffectDeps {
appId?: string;
/** Verify an ASC API key (keyId + issuerId via the .p8). */
verifyApiKey?: (args: {
keyId: string;
issuerId: string;
p8Content: Buffer;
}) => Promise<{
teamId?: string;
}>;
/**
* Create a distribution certificate from a CSR. Returns the RAW Apple cert
* response (mirrors the real apple-api `createCertificate` helper): the cert
* resource id, the base64 DER `certificateContent`, the expiry, and the team
* id. The engine pairs `certificateContent` + the CSR private key via
* `createP12` to produce the final .p12 — keeping the IO-free engine in charge
* of assembling the CertificateData credential. Throws CertificateLimitError
* (carrying the existing certs) when Apple's per-team cert limit is hit.
*/
createCertificate?: (args: {
csr: string;
accessToken?: string;
}) => Promise<{
certificateId: string;
certificateContent: string;
expirationDate: string;
teamId: string;
}>;
/** Revoke an existing certificate (cert-limit recovery). */
revokeCertificate?: (certificateId: string) => Promise<void>;
/** Create a provisioning profile for a bundle id + cert. */
createProfile?: (args: {
bundleId: string;
certificateId: string;
distribution?: string;
}) => Promise<ProfileData>;
/** Delete a provisioning profile (duplicate-profile recovery). */
deleteProfile?: (profileId: string) => Promise<void>;
/** Resolve the Apple cert resource id from a local cert SHA-1. */
findCertIdBySha1?: (sha1: string) => Promise<string | null>;
/** Classify a cert's Apple-side availability (import-validating-all-certs). */
classifyCertAvailability?: (identity: SigningIdentity) => Promise<EnrichedIdentityAvailability>;
/** List the team's distribution certificates (cert-limit prompt). */
listCertificates?: () => Promise<AscDistributionCert[]>;
/** Check for duplicate Capgo profiles for a bundle id. */
checkDuplicateProfiles?: (bundleId: string) => Promise<IosDuplicateProfile[]>;
/** Ensure the bundle id exists on Apple (import-create-profile-only). */
ensureBundleId?: (bundleId: string) => Promise<void>;
/**
* List the Apple profiles linked to a cert (import-checking-apple-cert).
*
* Returns the RAW Apple shape (AscProfileSummary[]) exactly as the real
* apple-api `listProfilesForCert` helper does — id / name / profileType /
* profileContent / expirationDate / bundleIdentifier. The engine itself
* synthesizes each summary into a DiscoveredProfile (populating profileBase64
* + certificateSha1s=[identity.sha1]) via `synthesizeProfileFromAscSummary`,
* byte-for-byte mirroring the TUI's inline mapping at app.tsx:1556 / :1460.
* Keeping the dep at the raw Apple shape means the driver pre-binds nothing
* more than the real helper.
*/
listProfilesForCert?: (certificateId: string) => Promise<AscProfileSummary[]>;
/** List every ASC app visible to the API key (verify-app fetch + Path B re-poll). */
listApps?: () => Promise<AscApp[]>;
/** List every registered bundle-id identifier (verify-app diagnostics). */
listBundleIds?: () => Promise<string[]>;
/**
* FRESH bundle-id detection from disk (verify-app + the Path-A re-check). The
* driver pre-binds the real `detectIosBundleIds({ cwd, iosDir, capacitorAppId })`
* — the engine reads `releaseResolved`/`pbxproj` for the authoritative Release
* id, `debug`/`debugReleaseDiffer` for the awareness note, and `capacitor` for
* the persisted iosBundleIdContextAppId snapshot. Called PER CHECK so an edit
* the user made since the wizard started is picked up (the TUI bypasses its
* memo the same way, app.tsx:1522/3088).
*/
detectBundleIds?: () => DetectedBundleIds;
/**
* Rewrite the Release PRODUCT_BUNDLE_IDENTIFIER assignments equal to `fromId`
* to `toId` in the Xcode project (the Path-A auto-fix). The driver pre-binds
* the real `writeReleaseBundleId(cwd, iosDir, …)`; returns the number of
* replaced assignments (0 = nothing matched). Throws only on an FS error.
*/
writeReleaseBundleId?: (fromId: string, toId: string) => {
changed: number;
};
/** Generate a CSR + private key PEM. */
generateCsr?: (args?: {
commonName?: string;
}) => {
csr: string;
privateKeyPem: string;
};
/** Build a .p12 from a cert + private key. Returns base64. */
createP12?: (args: {
certificatePem: string;
privateKeyPem: string;
password: string;
}) => string;
/** List the Mac's code-signing identities (import-scanning). */
listSigningIdentities?: () => Promise<SigningIdentity[]>;
/** Scan the Mac's on-disk provisioning profiles (import-scanning). */
scanProvisioningProfiles?: () => Promise<DiscoveredProfile[]>;
/**
* Export a .p12 (cert + key) from the Keychain for the chosen identity
* (import-exporting). Signature mirrors the REAL macos-signing helper VERBATIM:
* takes the identity's SHA-1 and resolves to { base64, passphrase } (the
* auto-generated wrap passphrase becomes the transient importedP12Password the
* saving-credentials handoff reads — NEVER persisted, risk #2 / D-iOS-3).
*/
exportP12FromKeychain?: (targetSha1: string) => Promise<ExportedP12>;
/** Parse a `.mobileprovision` file in detail (import-provide-profile-path). */
parseMobileprovisionDetailed?: (bytes: Buffer) => MobileprovisionDetail;
loadProgress?: (appId: string) => Promise<OnboardingProgress | null>;
saveProgress?: (appId: string, progress: OnboardingProgress) => Promise<void>;
deleteProgress?: (appId: string) => Promise<void>;
/** Persist the saved build-credential map (saving-credentials). */
updateSavedCredentials?: (appId: string, platform: 'ios' | 'android', credentials: Record<string, string>) => Promise<void>;
loadSavedCredentials?: (appId: string) => Promise<unknown>;
readFile?: (path: string) => Promise<Buffer>;
copyFile?: (src: string, dest: string) => Promise<void>;
/**
* Open the native .p8 file picker (p8-method-select). Resolves to the chosen
* absolute path, or null when the user cancels. The driver pre-binds the real
* `openFilePicker` here; tests inject a canned path/null. Mirrors the TUI's
* `openFilePicker()` call inside the p8-method-select effect.
*/
openP8FilePicker?: () => Promise<string | null>;
/**
* Open the native .mobileprovision file picker (import-provide-profile-path).
* Resolves to the chosen absolute path, or null when the user cancels. The
* driver pre-binds the real `openMobileprovisionPicker` here; tests inject a
* canned path/null. Mirrors the TUI's `openMobileprovisionPicker()` call
* inside the import-provide-profile-path effect (app.tsx:1696). The bytes are
* then read via `deps.readFile` and parsed via `deps.parseMobileprovisionDetailed`.
*/
openProfilePicker?: () => Promise<string | null>;
/**
* Whether the host can show a native file picker. Gates the
* import-no-match-recovery / import-portal-explanation "use a .mobileprovision
* from disk" option exactly as the TUI's `canUseFilePicker()` does
* (app.tsx:3570/3733). Defaults to true when omitted (the macOS-first target).
*/
canUseFilePicker?: () => boolean;
/**
* Open a URL in the host's default browser (import-portal-explanation's
* "open the portal anyway" branch). Best-effort — the driver pre-binds the
* real `open` helper; tests inject a recorder/no-op. Mirrors the TUI's
* `open(...)` call at app.tsx:3749. A failure must NOT abort recovery.
*/
openExternal?: (url: string) => Promise<void> | void;
/**
* Whether the host is macOS. Gates the post-backup fork: on macOS the user is
* offered import-vs-create at `setup-method-select`; off-macOS the import
* sub-flow is unavailable so backing-up routes straight to the create-new
* `api-key-instructions`. Mirrors the TUI's `isMacOS()` branch. Defaults to
* true when omitted (the macOS-first onboarding target).
*/
isMacOS?: () => boolean;
createCiSecretEntries?: (credentials: Partial<BuildCredentials>, apiKey?: string) => CiSecretEntry[];
detectCiSecretTargets?: (runner?: CommandRunner) => CiSecretDiscovery;
getCiSecretRepoLabelAsync?: (target: CiSecretTarget, runner?: AsyncCommandRunner) => Promise<string | null>;
listExistingCiSecretKeysAsync?: (target: CiSecretTarget, keys: string[], runner?: AsyncCommandRunner) => Promise<string[]>;
uploadCiSecretsAsync?: (target: CiSecretTarget, entries: CiSecretEntry[], existingKeys?: string[], runner?: AsyncCommandRunner, onProgress?: (current: number, total: number, keyName: string) => void) => Promise<void>;
exportCredentialsToEnv?: (opts: EnvExportOpts) => EnvExportResult;
defaultExportPath?: (appId: string, platform: 'ios' | 'android') => string;
/** Lockfile-based package-manager detection (the pick-package-manager 'recommended' note). */
detectPackageManager?: () => string;
generateWorkflow?: (opts: WorkflowGeneratorOpts) => GeneratedWorkflow;
writeWorkflowFile?: (opts: WorkflowGeneratorOpts, writeOptions?: WorkflowWriteOptions) => WorkflowWriteResult;
requestBuildInternal?: (appId: string, options: BuildRequestOptions, silent?: boolean, logger?: BuildLogger) => Promise<BuildRequestResult>;
/** The streaming BuildLogger threaded into requestBuildInternal (4th arg). */
logger?: BuildLogger;
/** The build VIEWER sink (FullscreenBuildOutput), distinct from onLog. */
onBuildOutput?: (line: string) => void;
/** Resolves the Capgo API key for the build request (CLI-flag-over-saved). */
resolveApikey?: () => string | undefined;
/** Per-key CI-secret upload progress (uploadCiSecretsAsync 5th arg). */
onCiSecretUploadProgress?: (current: number, total: number, keyName: string) => void;
/** The 2-phase checking-ci-secrets status text. */
onCiSecretCheckPhase?: (phase: string) => void;
/** The ci-secrets-failed reason. */
onCiSecretError?: (message: string) => void;
/** Reads the project's package.json scripts map (with-workflow 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'). */
trackWorkflowEvent?: (event: string, options?: {
decision?: string;
}) => void;
/**
* DRIVER-HELD transient tail state threaded back into each post-save effect.
* The TUI resolves these ONCE (at saving-credentials) and keeps them in React
* state; a headless driver mirrors that by capturing the matching
* IosEffectResult.transient and passing it back here on the NEXT effect.
* NEVER persisted to progress.json. When absent (crash-recovery resume) the
* effect falls back to a single lossy re-derivation from progress.
*/
carried?: {
savedCredentials?: Record<string, string>;
ciSecretEntries?: CiSecretEntry[];
ciSecretExistingKeys?: string[];
/**
* Whether the workflow file did NOT exist when previewed (the TUI's
* `previewIsNew`, resolved at preview-workflow-file via existsSync). The
* writing-workflow-file effect logs '✔ Wrote' vs '✔ Overwrote' from it.
* Absent defaults to NEW ('Wrote'). EPHEMERAL — never persisted.
*/
workflowIsNew?: boolean;
/** The chosen signing identity (lossy re-scan source on resume). */
chosenIdentity?: SigningIdentity;
/** The chosen provisioning profile (lossy re-scan source on resume). */
chosenProfile?: DiscoveredProfile;
/**
* The import-scanning discovery inventory (identity↔on-disk-profile matches +
* the raw scanned profiles), threaded forward so the NEXT import effect can
* read it without a re-scan. Produced by import-scanning into transient; the
* driver mirrors it back here for import-validating-all-certs (which batches
* classifyCertAvailability over importMatches) and the pickers. EPHEMERAL —
* never persisted; on a crash-recovery resume the engine re-lands on
* import-scanning and re-populates it.
*/
importMatches?: IdentityProfileMatch[];
importProfiles?: DiscoveredProfile[];
/** Resolved cert/profile/team export payloads carried into saving-credentials. */
certData?: CertificateData;
profileData?: ProfileData;
teamId?: string;
/**
* The validated .p8 file content (ASC private key) the driver carries
* between the .p8 input chain and `verifying-key`. ONLY the p8Path is
* persisted to progress.json — the raw key bytes ride this transient
* channel, mirroring the TUI's `p8ContentRef`. The verifying-key effect
* reads it from here; when absent (crash-recovery resume) it falls back to
* re-reading the file at `progress.p8Path` via `deps.readFile`.
*/
p8Content?: Buffer;
/**
* Tracks that the p8-method-select file-picker effect already ran, so a
* re-render does NOT re-open the native picker. Mirrors the TUI's
* `pickerOpenedRef`. The driver threads the returned `pickerOpened: true`
* transient back here on the next call.
*/
pickerOpened?: boolean;
/**
* Tracks that the import-provide-profile-path .mobileprovision file-picker
* effect already ran this attempt, so a re-render / re-drive does NOT re-open
* the native picker. SEPARATE from `pickerOpened` (the .p8 picker guard) so
* the two file pickers never cross-suppress each other — mirrors the TUI's
* distinct `mobileprovisionPickerOpenedRef` (app.tsx:1689). The driver threads
* the returned `profilePickerOpened: true` transient back here; it RESETS the
* flag (to false) before routing into import-provide-profile-path from the
* recovery menu, exactly as the TUI clears the ref at app.tsx:3593.
*/
profilePickerOpened?: boolean;
/**
* Keychain export passphrase for the IMPORT path's .p12 (import-exporting).
* Transient only — the import-exporting effect never persists it, so the
* saving-credentials handoff reads it from carried. Absent on the create-new
* path (which uses the well-known DEFAULT_P12_PASSWORD) and on a crash-recovery
* resume that lost the in-memory state.
*/
importedP12Password?: string;
/**
* The existing Apple Distribution certs surfaced when the per-team cert
* limit was hit (cert-limit recovery). Produced by `creating-certificate`
* into transient.existingCerts; the driver threads the list back here so a
* parked `cert-limit-prompt` re-renders (and resolves the user's pick by
* cert id) WITHOUT re-hitting Apple. EPHEMERAL — never persisted (the TUI's
* `existingCerts` React state); cleared together with `certToRevoke` after
* a successful revoke. A restart that loses it re-enters via a fresh
* creating-certificate attempt, which re-derives the list.
*/
existingCerts?: AscDistributionCert[];
/**
* The cert the user picked at `cert-limit-prompt` (cert-limit recovery). The
* choice is EPHEMERAL — `applyIosInput` persists nothing; the driver records
* the picked AscDistributionCert here and re-drives the prompt as a resolver
* effect, exactly as the TUI stashes `certToRevoke` in React state before
* advancing to `revoking-certificate` (app.tsx:3923). The resolver returns
* `revoking-certificate` when present, `error` when absent (the user exited).
* Mirrors the BATCH 2 ephemeral-branching mechanism (`pickerOpened` /
* `chosenIdentity`): the selection lives in carried, never in progress.json.
*/
certToRevoke?: AscDistributionCert;
/**
* The duplicate Capgo profiles surfaced at `duplicate-profile-prompt`
* (duplicate-profile recovery). Produced by `creating-profile` /
* `import-create-profile-only` into transient; the driver threads the list
* back here so `deleting-duplicate-profiles` knows which profiles to delete.
* NEVER persisted (only `duplicateProfileOrigin` is — see types.ts).
*/
duplicateProfiles?: IosDuplicateProfile[];
/**
* The user's confirm/exit decision at `duplicate-profile-prompt`. EPHEMERAL —
* `applyIosInput` persists nothing (per the audit's sequencing model); the
* driver records the choice here and re-drives the prompt as a resolver
* effect. `true` → `deleting-duplicate-profiles`; falsy (the user exited) →
* `error` (mirroring app.tsx:3942's delete-vs-exitOnboarding branch).
*/
confirmDeleteDuplicates?: boolean;
/**
* The user's pick at `import-no-match-recovery` (the 5-way HUB). EPHEMERAL —
* `applyIosInput` persists nothing; the driver records the choice here and
* re-drives the prompt as a resolver effect. 'create' →
* import-create-profile-only (with an ASC key) or api-key-instructions
* (without); 'provide-profile-path' → import-provide-profile-path; 'browser'
* → import-portal-explanation; 'back' → import-pick-identity. Mirrors the
* TUI's recovery-menu onChange (app.tsx:3579).
*/
recoveryAction?: 'create' | 'provide-profile-path' | 'browser' | 'back';
/**
* The user's pick at `import-portal-explanation` (the manual-portal
* walkthrough). EPHEMERAL — the driver records the choice here and re-drives
* the step as a resolver. 'use-create' → import-create-profile-only;
* 'use-file' → import-provide-profile-path; 'open-anyway' / 'back' →
* import-no-match-recovery. Mirrors app.tsx:3738.
*/
portalAction?: 'use-create' | 'open-anyway' | 'use-file' | 'back';
/**
* The user's pick at `import-export-warning` (the heads-up before the one
* Keychain dialog). EPHEMERAL — `applyIosInput` persists nothing; the driver
* records the choice here and re-drives the step as a resolver. 'go' →
* import-exporting (the precompiled signed helper is resolved + verified in
* the export step itself — PR #2458 removed the swiftc compile step);
* 'back' → import-pick-profile; 'exit'/absent → exit onboarding. Mirrors
* app.tsx:3769 onChange.
*/
exportWarningAction?: 'go' | 'back' | 'exit';
/**
* The STICKY no-match reason set by the step that ROUTED into recovery
* (import-pick-identity / import-checking-apple-cert). The recovery resolver
* + the import-provide-profile-path cancel branch thread it back so a
* re-entry from a file-picker cancel / portal "open anyway" does NOT
* recompute or overwrite it (risk #8) — the menu keeps showing the SAME
* variant. Mirrors the TUI leaving `noMatchReason` untouched on back-nav.
*/
noMatchReason?: IosNoMatchReason;
/**
* The failing step's human error message — the error screen's content
* (BATCH 8). EPHEMERAL — set by the failing effect into transient.error and
* threaded back here by the driver so a parked 'error' view re-renders the
* message (iosViewForStep('error') reads ctx.error). NEVER persisted —
* mirrors the TUI's setError React state; a crash-recovery resume re-enters
* the failing phase fresh.
*/
error?: string;
/**
* The step to re-run when the user picks "Try again" on the error screen
* (BATCH 8). EPHEMERAL — set by the failing effect into transient.retryStep,
* threaded back here by the driver, and read by the error RESOLVER
* (runIosEffect('error')) to route a retry. NEVER persisted: an error is
* transient runtime state, so a crash-recovery resume re-enters the failing
* phase fresh (getIosResumeStep never returns 'error'). Mirrors the TUI's
* setRetryStep + the ErrorStep 'retry' branch (app.tsx:1116 / 4468).
*/
retryStep?: OnboardingStep;
/**
* The user's pick on the error screen (BATCH 8). EPHEMERAL — `applyIosInput`
* persists nothing; the driver records the choice here and re-drives the step
* as a resolver. 'retry' → re-run carried.retryStep (the failing step);
* 'restart' → welcome (a fresh reset); 'exit'/absent → stay on 'error' (the
* terminal exit sink — the driver leaves onboarding, mirroring the TUI's
* exitOnboarding at app.tsx:4482). NEVER persisted.
*/
errorAction?: 'retry' | 'restart' | 'exit';
/**
* Where verify-app routes once the invariant holds (or on a pass-through
* exit): the import continuation (import-validating-all-certs /
* import-pick-identity) on the import app_store path, absent on create-new
* (verify-app falls back to 'creating-certificate'). Produced by
* verifying-key into transient.pendingVerifyNext; the driver threads it back
* here. NEVER persisted — a fresh mount has none, so a resume re-entering
* verify-app always falls back to creating-certificate (matching the TUI's
* pendingVerifyNext React state + getResumeStep's verify-app comment).
*/
pendingVerifyNext?: OnboardingStep;
/**
* The user's pick on the PARKED verify-app step (the picker or one of the
* two gates). EPHEMERAL — `applyIosInput` persists nothing; the driver
* records the pick here and re-drives verify-app as a resolver effect:
* 'pick' (+ verifyChosenApp) / 'create-new' route the picker; 'autofix' /
* 'continue' drive the Path-A fix-build-id gate; 'recheck' / 'open' /
* 'reopen' drive the Path-B create-app gate; 'back' resets to the picker;
* 'cancel' exits via the error sink. Mirrors the TUI Select onChange values
* (app.tsx:3246/3283/3323/3360). The driver MUST clear it after each
* resolver run so a later re-entry runs the initial fetch.
*/
verifyAction?: 'pick' | 'create-new' | 'autofix' | 'continue' | 'recheck' | 'open' | 'reopen' | 'back' | 'cancel';
/** The existing ASC app picked in the verify-app picker (Path A target). */
verifyChosenApp?: AscAppLike | null;
/** The ASC apps fetched by the initial verify-app effect (picker source + re-poll). */
verifyApps?: AscApp[];
/** Registered Developer-portal bundle ids (Path B wording sharpener). */
verifyRegisteredIds?: string[];
/** The authoritative Release build id resolved by the verify-app fresh detect. */
verifyReleaseBundleId?: string;
/** The Debug-config bundle id when it differs from Release (else ''). */
verifyDebugBundleId?: string;
/** Which gate path the user is on (null = the picker). */
verifyPath?: GatePath | null;
/** 1-based count of blocked Continue attempts (the escalation driver). */
verifyAttempt?: number;
/** Path B: ask before re-opening the browser after a blocked re-poll. */
verifyAskReopen?: 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;
signal?: AbortSignal;
}
export interface IosEffectResult {
/** Updated progress after the effect ran (matches what was persisted). */
progress: OnboardingProgress;
/** Explicit next step when not derivable from progress alone (★ transitions). */
next?: OnboardingStep;
/** Transient runtime data that lives in the driver but is NOT persisted. */
transient?: Partial<IosStepCtx>;
}
/**
* The create-new choice/input vocabulary. Mirrors android's `AndroidInput`:
* one variant per choice/input step that records (or routes) state. The iOS
* `applyIosInput` signature still accepts `unknown`, so callers cast to this.
* Navigation-only choices (api-key-instructions) are included for completeness
* but return progress unchanged.
*/
export type IosInput = {
step: 'setup-method-select';
value: 'create' | 'import';
} | {
step: 'api-key-instructions';
value: 'picker' | 'manual';
} | {
step: 'input-p8-path';
value: string;
} | {
step: 'input-key-id';
value: string;
} | {
step: 'input-issuer-id';
value: string;
} | {
step: 'cert-limit-prompt';
value: string;
} | {
step: 'duplicate-profile-prompt';
value: 'delete' | 'exit';
} | {
step: 'verify-app';
value: string;
} | {
step: 'import-distribution-mode';
value: 'app_store' | 'ad_hoc' | '__cancel__';
} | {
step: 'import-pick-identity';
value: string;
} | {
step: 'import-pick-profile';
value: string;
} | {
step: 'import-no-match-recovery';
value: 'create' | 'provide-profile-path' | 'browser' | 'back';
} | {
step: 'import-portal-explanation';
value: 'use-create' | 'open-anyway' | 'use-file' | 'back';
} | {
step: 'import-export-warning';
value: 'go' | 'back' | 'exit';
};
/**
* Build the view-model for a given step. Post-save tail steps delegate to the
* shared neutral view (adapted back to IosStepView). The create-new choice/input
* steps (setup-method fork + .p8 chain) return real per-step views mirroring the
* TUI prompts/options (ui/steps/ios-credentials.tsx). All other steps return a
* minimal placeholder 'auto' view echoing the step (real per-step views land in
* later batches).
*/
export declare function iosViewForStep(step: OnboardingStep, progress: OnboardingProgress, ctx?: IosStepCtx): IosStepView;
/**
* Apply a user input to progress. Post-save tail choice/input steps delegate
* the reducer to the shared neutral module. The create-new choice/input steps
* persist their field(s) exactly as the TUI's onSubmit/onChange handlers do
* (ui/app.tsx). All other steps return progress unchanged (real per-step
* mutations land in later batches).
*
* PURE — no IO. The .p8 file read + keyId extraction + Apple verification are
* effect-boundary concerns (p8-method-select / verifying-key); the reducers here
* only record the raw user input into progress.
*/
export declare function applyIosInput(step: OnboardingStep, progress: OnboardingProgress, input: unknown): OnboardingProgress;
/**
* Run the async side-effect for a step. Post-save tail steps (incl.
* saving-credentials) delegate to the shared neutral module via toTailDeps; the
* neutral result maps 1:1 onto IosEffectResult (next is a wider OnboardingStep;
* transient is a subset of IosStepCtx). All other steps are not implemented yet
* — the real Apple-API / keychain / build effects land in later batches.
*/
export declare function runIosEffect(step: OnboardingStep, progress: OnboardingProgress, deps: IosEffectDeps): Promise<IosEffectResult>;