From e28991bd80359468929496ffb589ff3efd1a2113 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 10:25:14 +0200 Subject: [PATCH 01/22] feat(cli): guided App Store Connect API key via precompiled macOS helper Add `build credentials apple-key` (alias `asc-key`): on macOS it launches a precompiled Swift helper that walks the user through creating an App Store Connect team API key in an embedded browser, auto-captures issuer id + key id, intercepts the one-time .p8, validates it with Apple, and (with --appId) saves it into iOS build credentials. The helper streams an NDJSON 'stats protocol' on stdout (tagged capgoAscKey:1): - protocol.ts: typed contract + tolerant streaming parser + PostHog mapping, with a hard secret guard so a private key can never reach analytics. - helper.ts: locate the precompiled binary (CAPGO_ASC_KEY_HELPER_PATH or the ~/.capgo cache), spawn it, forward each event line to trackEvent on the 'app-store-connect-key' channel, and return the credentials from the terminal result line. - command.ts: the command action (macOS gate, progress, optional credential save, --json output, analytics flush). - scripts/build-asc-key-helper.sh: build a universal release binary. - PROTOCOL.md: the wire-format + event contract. - test/test-asc-key-protocol.mjs: 9 parser/mapping/secret-guard tests. Telemetry honours CAPGO_DISABLE_TELEMETRY. The binary is not shipped in the npm tarball (macOS-only); it is located/downloaded at runtime. --- cli/package.json | 1 + cli/scripts/build-asc-key-helper.sh | 70 +++++++ cli/src/build/onboarding/asc-key/PROTOCOL.md | 76 +++++++ cli/src/build/onboarding/asc-key/command.ts | 115 +++++++++++ cli/src/build/onboarding/asc-key/helper.ts | 159 ++++++++++++++ cli/src/build/onboarding/asc-key/protocol.ts | 207 +++++++++++++++++++ cli/src/index.ts | 20 ++ cli/test/test-asc-key-protocol.mjs | 108 ++++++++++ 8 files changed, 756 insertions(+) create mode 100755 cli/scripts/build-asc-key-helper.sh create mode 100644 cli/src/build/onboarding/asc-key/PROTOCOL.md create mode 100644 cli/src/build/onboarding/asc-key/command.ts create mode 100644 cli/src/build/onboarding/asc-key/helper.ts create mode 100644 cli/src/build/onboarding/asc-key/protocol.ts create mode 100644 cli/test/test-asc-key-protocol.mjs diff --git a/cli/package.json b/cli/package.json index 918cfb23f4..f4cd91aee2 100644 --- a/cli/package.json +++ b/cli/package.json @@ -72,6 +72,7 @@ "test:upload": "bun test/test-upload-validation.mjs", "test:fail-on-incompatible": "bun test/test-fail-on-incompatible.mjs", "test:credentials": "bun test/test-credentials.mjs", + "test:asc-key-protocol": "bun test/test-asc-key-protocol.mjs", "test:credentials-validation": "bun test/test-credentials-validation.mjs", "test:android-service-account-validation": "bun test/test-android-service-account-validation.mjs", "test:build-zip-filter": "bun test/test-build-zip-filter.mjs", diff --git a/cli/scripts/build-asc-key-helper.sh b/cli/scripts/build-asc-key-helper.sh new file mode 100755 index 0000000000..bb0b3958ac --- /dev/null +++ b/cli/scripts/build-asc-key-helper.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Build the precompiled App Store Connect API-key helper (a native macOS Swift +# app) as a universal (arm64 + x86_64) release binary, ready to ship as a +# downloadable artifact for `@capgo/cli build credentials apple-key`. +# +# The CLI itself does NOT bundle this macOS-only binary in its npm tarball +# (that would bloat every Linux/Windows install). Instead it locates the binary +# at runtime via: +# 1. CAPGO_ASC_KEY_HELPER_PATH (dev / CI / this script's output) +# 2. ~/.capgo/asc-key-helper/capgo-asc-key-helper (cached download) +# +# Usage: +# scripts/build-asc-key-helper.sh [out-dir] +# +# Example: +# scripts/build-asc-key-helper.sh ~/Developer/test-p8-extract dist-helper +# export CAPGO_ASC_KEY_HELPER_PATH="$PWD/dist-helper/capgo-asc-key-helper" +# +set -euo pipefail + +if [[ "$(uname)" != "Darwin" ]]; then + echo "error: the ASC key helper can only be built on macOS." >&2 + exit 1 +fi + +SRC_DIR="${1:-}" +OUT_DIR="${2:-dist-helper}" +PRODUCT_NAME="P8Extract" # SwiftPM executable product name +OUT_BINARY="capgo-asc-key-helper" # canonical name the CLI looks for + +if [[ -z "$SRC_DIR" || ! -f "$SRC_DIR/Package.swift" ]]; then + echo "error: pass the helper Swift package dir (the folder with Package.swift)." >&2 + echo "usage: $0 [out-dir]" >&2 + exit 1 +fi + +echo "› Building universal release binary from $SRC_DIR ..." +swift build \ + --package-path "$SRC_DIR" \ + -c release \ + --arch arm64 \ + --arch x86_64 + +BUILT="$SRC_DIR/.build/apple/Products/Release/$PRODUCT_NAME" +if [[ ! -f "$BUILT" ]]; then + # Fall back to the single-arch path if a universal build wasn't produced. + BUILT="$(find "$SRC_DIR/.build" -maxdepth 3 -name "$PRODUCT_NAME" -type f -perm -111 | head -1)" +fi +if [[ -z "${BUILT:-}" || ! -f "$BUILT" ]]; then + echo "error: could not find built product '$PRODUCT_NAME' under $SRC_DIR/.build" >&2 + exit 1 +fi + +mkdir -p "$OUT_DIR" +cp "$BUILT" "$OUT_DIR/$OUT_BINARY" +chmod +x "$OUT_DIR/$OUT_BINARY" + +echo "› Architectures:" +lipo -info "$OUT_DIR/$OUT_BINARY" || true +echo "› SHA-256:" +shasum -a 256 "$OUT_DIR/$OUT_BINARY" + +echo "" +echo "✅ Built $OUT_DIR/$OUT_BINARY" +echo " Try it: CAPGO_ASC_KEY_HELPER_PATH=\"$PWD/$OUT_DIR/$OUT_BINARY\" npx @capgo/cli build credentials apple-key" +echo "" +echo "ℹ️ For distribution, codesign + notarize this binary before publishing:" +echo " codesign --force --options runtime --sign \"Developer ID Application: …\" \"$OUT_DIR/$OUT_BINARY\"" +echo " xcrun notarytool submit … && xcrun stapler staple …" diff --git a/cli/src/build/onboarding/asc-key/PROTOCOL.md b/cli/src/build/onboarding/asc-key/PROTOCOL.md new file mode 100644 index 0000000000..ad8b966d67 --- /dev/null +++ b/cli/src/build/onboarding/asc-key/PROTOCOL.md @@ -0,0 +1,76 @@ +# App Store Connect key helper — stdout stats protocol + +The `build credentials apple-key` command launches a native macOS helper (a +precompiled Swift app) that walks the user through creating an App Store Connect +**team** API key in an embedded browser, then captures the resulting credentials. + +While it runs, the helper streams a **stats protocol** on **stdout** so the CLI +can forward usage statistics to PostHog and receive the final credentials. This +document is the contract between the Swift helper (`StatsProtocol.swift`) and the +CLI (`protocol.ts` / `helper.ts`). + +## Wire format + +Newline-delimited JSON ("NDJSON"). One JSON object per line on **stdout**. Every +line is tagged with `capgoAscKey` (the protocol version) so the reader can +ignore incidental stdout chatter. Human-readable diagnostics go to **stderr** +and are NOT part of this protocol. + +```jsonc +{"capgoAscKey":1,"kind":"event","ts":12,"runId":"","name":"step_changed","props":{ }} +{"capgoAscKey":1,"kind":"result","ts":900,"runId":"","ok":true,"keyId":"…","issuerId":"…","privateKey":"…"} +{"capgoAscKey":1,"kind":"result","ts":900,"runId":"","ok":false,"errorCode":"USER_CANCELLED","message":"…"} +``` + +| Field | Lines | Meaning | +| ------------- | ------------ | ---------------------------------------------------- | +| `capgoAscKey` | all | Protocol version (currently `1`). | +| `kind` | all | `"event"` or `"result"`. | +| `ts` | all | Milliseconds since the helper started. | +| `runId` | all | UUID correlating every line of one run. | +| `name` | event | snake_case event name. | +| `props` | event | Non-sensitive properties (never the private key). | +| `ok` | result | `true` = credentials present; `false` = error. | +| `keyId`/`issuerId`/`privateKey` | result (ok) | The captured credentials. | +| `errorCode`/`message` | result (!ok)| Failure reason. | + +### Rules + +- **`event` lines are forwarded to PostHog** (channel `app-store-connect-key`). +- The **terminal `result` line** carries the credentials on success. The + `privateKey` appears **only** here and is **never** forwarded to analytics. + As defence-in-depth, the CLI also strips any prop key matching + `private_key|secret|p8|pem|password|token` before sending tags. +- The reader tolerates non-protocol stdout lines (it skips anything without a + matching `capgoAscKey`), partial lines split across chunks, and a final + newline-less line. + +## Events + +| `name` | `props` | Emitted when | +| --------------------- | ---------------------------------------------------- | ----------------------------------------- | +| `helper_started` | `protocol_version`, `os_version` | The helper window appears. | +| `signed_in` | `team_count` | First authenticated session read. | +| `team_confirmed` | `is_switch`, `team_count` | User confirms a team in the dialog. | +| `api_access_checked` | `enabled`, `role_ok` | Team API-access capability is determined. | +| `api_access_denied` | `reason` (`not_enabled` \| `insufficient_role`) | Team can't create a key. | +| `step_changed` | `from`, `to`, `elapsed_ms_on_prev` | The guided step advances (the funnel). | +| `validation_started` | — | The new key is validated against Apple. | +| `validation_succeeded`| `duration_ms` | Validation passed. | +| `validation_failed` | `duration_ms` | Validation failed. | +| `helper_finished` | `ok`, `outcome` (`created`\|`cancelled`), `total_ms` | Just before the helper exits. | + +New events can be added without bumping the protocol version — the CLI forwards +any `event` line generically (humanized name + `prop_*` tags). Bump +`ASC_PROTOCOL_VERSION` only for breaking envelope changes. + +## Distribution of the precompiled helper + +The macOS-only binary is **not** bundled in the npm tarball. The CLI locates it +at runtime: + +1. `CAPGO_ASC_KEY_HELPER_PATH` — explicit override (dev / CI). +2. `~/.capgo/asc-key-helper/capgo-asc-key-helper` — cached download. + +Build a universal binary from the helper Swift package with +`scripts/build-asc-key-helper.sh `. diff --git a/cli/src/build/onboarding/asc-key/command.ts b/cli/src/build/onboarding/asc-key/command.ts new file mode 100644 index 0000000000..380fdc737e --- /dev/null +++ b/cli/src/build/onboarding/asc-key/command.ts @@ -0,0 +1,115 @@ +import { Buffer } from 'node:buffer' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { exit, platform, stdout } from 'node:process' +import { intro, log, outro } from '@clack/prompts' +import { flushAnalytics, trackEvent } from '../../../analytics/track' +import { checkAlerts } from '../../../api/update' +import { updateSavedCredentials } from '../../credentials' +import { isMacOS, NotMacOSError, resolveHelperBinary, runAscKeyHelper } from './helper' +import { ASC_KEY_CHANNEL } from './protocol' + +export interface CreateAppleKeyOptions { + apikey?: string + /** When set, the captured key is saved into this app's iOS build credentials. */ + appId?: string + /** Save into the per-project .capgo-credentials.json instead of the global file. */ + local?: boolean + /** Print the captured Key ID / Issuer ID / .p8 path as JSON on stdout. */ + json?: boolean +} + +/** + * Guided creation of an App Store Connect **team** API key. Launches the native + * macOS helper (a precompiled Swift app that walks the user through Apple's web + * UI in an embedded browser), streams its stats protocol to PostHog, and + * captures the resulting key — issuer id, key id and the one-time .p8 — without + * the user ever copy-pasting a credential. + */ +export async function createAppleKeyCommand(options: CreateAppleKeyOptions = {}): Promise { + await checkAlerts() + intro('App Store Connect API Key 🔑') + + if (!isMacOS()) { + log.error('This guided flow needs the macOS helper app and only runs on macOS.') + log.info('On other platforms, create the key manually at https://appstoreconnect.apple.com/access/integrations/api ' + + 'then save it with `npx @capgo/cli build credentials save --platform ios --apple-key --apple-key-id --apple-issuer-id `.') + void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Unsupported Platform', icon: '🔑', apikey: options.apikey, tags: { os_platform: platform } }) + await flushAnalytics() + exit(1) + } + + if (!resolveHelperBinary()) { + log.error('Could not find the App Store Connect key helper binary.') + log.info('Set CAPGO_ASC_KEY_HELPER_PATH to a compiled helper, or upgrade to a CLI release that bundles it.') + void trackEvent({ channel: ASC_KEY_CHANNEL, event: 'ASC Key: Helper Missing', icon: '🔑', apikey: options.apikey }) + await flushAnalytics() + exit(1) + } + + log.step('Opening the guided helper… complete the steps in the window that appears.') + + try { + const outcome = await runAscKeyHelper({ + apikey: options.apikey, + onEvent: (event) => { + // Surface a few high-signal milestones in the terminal as they happen. + if (event.name === 'signed_in') + log.info('Signed in to App Store Connect.') + else if (event.name === 'api_access_denied') + log.warn(`API access unavailable for this team (${String(event.props.reason ?? 'unknown')}).`) + else if (event.name === 'validation_started') + log.step('Validating the new key with Apple…') + }, + }) + + if (!outcome.ok) { + if (outcome.errorCode === 'USER_CANCELLED') + log.warn('Cancelled — no key was created.') + else + log.error(`Key creation failed (${outcome.errorCode}): ${outcome.message}`) + await flushAnalytics() + exit(1) + } + + const { credentials, eventCount } = outcome + const p8Path = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${credentials.keyId}.p8`) + + log.success('App Store Connect API key created and validated.') + log.info(`Key ID: ${credentials.keyId}`) + log.info(`Issuer ID: ${credentials.issuerId}`) + log.info(`Saved .p8: ${p8Path}`) + + let savedToAppId: string | undefined + if (options.appId) { + const appleKeyContent = Buffer.from(credentials.privateKey, 'utf-8').toString('base64') + await updateSavedCredentials(options.appId, 'ios', { + APPLE_KEY_ID: credentials.keyId, + APPLE_ISSUER_ID: credentials.issuerId, + APPLE_KEY_CONTENT: appleKeyContent, + }, options.local) + savedToAppId = options.appId + log.success(`Saved to ${options.local ? 'local' : 'global'} build credentials for ${options.appId}.`) + } + else { + log.info('Save it into your build credentials with:') + log.info(` npx @capgo/cli build credentials save --platform ios --apple-key "${p8Path}" --apple-key-id "${credentials.keyId}" --apple-issuer-id "${credentials.issuerId}" --appId `) + } + + if (options.json) { + // Deliberately excludes the private key — it's on disk at p8Path. + stdout.write(`${JSON.stringify({ keyId: credentials.keyId, issuerId: credentials.issuerId, p8Path, savedToAppId, eventCount })}\n`) + } + + await flushAnalytics() + outro('Done 🎉') + } + catch (error) { + if (error instanceof NotMacOSError) + log.error(error.message) + else + log.error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`) + await flushAnalytics() + exit(1) + } +} diff --git a/cli/src/build/onboarding/asc-key/helper.ts b/cli/src/build/onboarding/asc-key/helper.ts new file mode 100644 index 0000000000..0e9ff60271 --- /dev/null +++ b/cli/src/build/onboarding/asc-key/helper.ts @@ -0,0 +1,159 @@ +import type { Buffer } from 'node:buffer' +import type { AscCredentials, AscEventLine, AscProtocolLine, AscResultLine } from './protocol' +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { env, platform } from 'node:process' +import { trackEvent } from '../../../analytics/track' +import { ascEventToTrack, AscProtocolParser } from './protocol' + +/** Name of the precompiled helper binary as bundled / cached on disk. */ +const HELPER_BINARY_NAME = 'capgo-asc-key-helper' + +/** Thrown when the helper is requested on a non-macOS host. */ +export class NotMacOSError extends Error { + constructor() { + super('The App Store Connect key helper only runs on macOS.') + this.name = 'NotMacOSError' + } +} + +export function isMacOS(): boolean { + return platform === 'darwin' +} + +/** + * Locate the precompiled Swift helper binary, in priority order: + * 1. `CAPGO_ASC_KEY_HELPER_PATH` — explicit override (dev / CI / tests). + * 2. `~/.capgo/asc-key-helper/` — the cached download location. + * Returns `null` when none exists, so the caller can show install guidance. + */ +export function resolveHelperBinary(): string | null { + const override = env.CAPGO_ASC_KEY_HELPER_PATH + if (override && existsSync(override)) + return override + const cached = join(homedir(), '.capgo', 'asc-key-helper', HELPER_BINARY_NAME) + if (existsSync(cached)) + return cached + return null +} + +export interface AscHelperSuccess { + ok: true + credentials: AscCredentials + runId: string + /** Number of stats events the helper emitted during the run. */ + eventCount: number +} + +export interface AscHelperFailure { + ok: false + errorCode: string + message: string + runId: string +} + +export type AscHelperOutcome = AscHelperSuccess | AscHelperFailure + +export interface RunAscKeyHelperOptions { + /** Pre-resolved helper binary path (tests inject a fake; prod auto-resolves). */ + helperPathOverride?: string + /** API key for analytics attribution; falls back to the saved key. */ + apikey?: string + /** Optional observer for every event line (UI progress / tests). */ + onEvent?: (event: AscEventLine) => void + /** Forward events to PostHog via trackEvent. Defaults to true. */ + forwardToAnalytics?: boolean +} + +/** + * Launch the precompiled helper, stream its NDJSON stats protocol, forward each + * `event` line to PostHog, and resolve with the credentials from the terminal + * `result` line. The private key is returned to the caller but NEVER forwarded + * to analytics. Never rejects on a helper-side failure — returns a structured + * failure outcome instead (it throws only for {@link NotMacOSError}). + */ +export async function runAscKeyHelper(options: RunAscKeyHelperOptions = {}): Promise { + if (!isMacOS()) + throw new NotMacOSError() + + const binary = options.helperPathOverride ?? resolveHelperBinary() + if (!binary) { + return { + ok: false, + errorCode: 'HELPER_NOT_FOUND', + message: 'Could not locate the App Store Connect key helper binary. ' + + 'Set CAPGO_ASC_KEY_HELPER_PATH to a compiled helper, or use a CLI ' + + 'release that bundles it.', + runId: '', + } + } + + const forward = options.forwardToAnalytics !== false + + return new Promise((resolve) => { + const child = spawn(binary, [], { stdio: ['ignore', 'pipe', 'pipe'] }) + const parser = new AscProtocolParser() + let result: AscResultLine | undefined + let stderr = '' + let eventCount = 0 + let runId = '' + + const handleLine = (line: AscProtocolLine): void => { + if (line.runId) + runId = line.runId + if (line.kind === 'event') { + eventCount += 1 + options.onEvent?.(line) + if (forward) { + const mapped = ascEventToTrack(line) + // Fire-and-forget; telemetry must never block or break the flow. + void trackEvent({ ...mapped, apikey: options.apikey }) + } + } + else { + // Keep the last result line as authoritative. + result = line + } + } + + child.stdout?.setEncoding('utf-8') + child.stdout?.on('data', (chunk: string) => { + for (const line of parser.push(chunk)) + handleLine(line) + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8') + }) + child.once('error', (err) => { + resolve({ + ok: false, + errorCode: 'SPAWN_FAILED', + message: err instanceof Error ? err.message : String(err), + runId, + }) + }) + child.once('close', (code) => { + for (const line of parser.flush()) + handleLine(line) + + if (result?.ok && result.keyId && result.issuerId && result.privateKey) { + resolve({ + ok: true, + credentials: { keyId: result.keyId, issuerId: result.issuerId, privateKey: result.privateKey }, + runId, + eventCount, + }) + return + } + + const errorCode = result?.errorCode ?? (code === 1 ? 'USER_CANCELLED' : 'NO_RESULT') + const message = result?.message + ?? (code === 1 + ? 'Helper was cancelled before delivering a key.' + : `Helper exited (code ${code}) without a result line.${stderr.trim() ? ` Stderr: ${stderr.trim()}` : ''}`) + resolve({ ok: false, errorCode, message, runId }) + }) + }) +} diff --git a/cli/src/build/onboarding/asc-key/protocol.ts b/cli/src/build/onboarding/asc-key/protocol.ts new file mode 100644 index 0000000000..3b389d0331 --- /dev/null +++ b/cli/src/build/onboarding/asc-key/protocol.ts @@ -0,0 +1,207 @@ +// Shared contract for the App Store Connect API-key helper's stdout "stats +// protocol". The native Swift helper (see the asc-key-helper repo / +// StatsProtocol.swift) writes newline-delimited JSON ("NDJSON") to stdout — one +// self-describing envelope per line, tagged with `capgoAscKey` (the protocol +// version) so we can ignore incidental stdout chatter: +// +// {"capgoAscKey":1,"kind":"event","ts":12,"runId":"...","name":"step_changed","props":{}} +// {"capgoAscKey":1,"kind":"result","ts":900,"runId":"...","ok":true,"keyId":"...","issuerId":"...","privateKey":"..."} +// {"capgoAscKey":1,"kind":"result","ts":900,"runId":"...","ok":false,"errorCode":"USER_CANCELLED","message":"..."} +// +// `event` lines are forwarded to PostHog. The terminal `result` line carries the +// credentials on success and is the ONLY place the private key ever appears — +// it must never be forwarded to analytics. Human diagnostics stay on stderr and +// are not part of this protocol. + +/** Protocol version understood by this CLI. Bumped on breaking changes. */ +export const ASC_PROTOCOL_VERSION = 1 + +/** Analytics channel every forwarded helper event is sent on. */ +export const ASC_KEY_CHANNEL = 'app-store-connect-key' + +/** A non-sensitive analytics event emitted by the helper. */ +export interface AscEventLine { + capgoAscKey: number + kind: 'event' + /** Milliseconds since the helper started. */ + ts: number + /** Correlates every line of a single helper run. */ + runId: string + /** snake_case event name, e.g. `step_changed`, `validation_succeeded`. */ + name: string + /** Non-sensitive properties. NEVER contains the private key. */ + props: Record +} + +/** The captured credentials, delivered on the terminal success line. */ +export interface AscCredentials { + keyId: string + issuerId: string + privateKey: string +} + +/** Terminal line: success carries credentials, failure carries an error. */ +export interface AscResultLine { + capgoAscKey: number + kind: 'result' + ts: number + runId: string + ok: boolean + // Success fields: + keyId?: string + issuerId?: string + privateKey?: string + // Failure fields: + errorCode?: string + message?: string +} + +export type AscProtocolLine = AscEventLine | AscResultLine + +/** + * Parse a single raw stdout line into a protocol envelope, or `null` when the + * line is not part of the protocol (blank line, incidental chatter, wrong + * version, or malformed JSON). Never throws — a misbehaving helper must not + * crash the CLI. + */ +export function parseAscProtocolLine(line: string): AscProtocolLine | null { + const trimmed = line.trim().replace(/^\uFEFF/, '') + if (!trimmed || trimmed[0] !== '{') + return null + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } + catch { + return null + } + if (!parsed || typeof parsed !== 'object') + return null + const obj = parsed as Record + // The version tag is what distinguishes a protocol line from random JSON. + if (obj.capgoAscKey !== ASC_PROTOCOL_VERSION) + return null + if (obj.kind === 'event') { + if (typeof obj.name !== 'string') + return null + return { + capgoAscKey: ASC_PROTOCOL_VERSION, + kind: 'event', + ts: typeof obj.ts === 'number' ? obj.ts : 0, + runId: typeof obj.runId === 'string' ? obj.runId : '', + name: obj.name, + props: (obj.props && typeof obj.props === 'object') ? obj.props as Record : {}, + } + } + if (obj.kind === 'result') { + return { + capgoAscKey: ASC_PROTOCOL_VERSION, + kind: 'result', + ts: typeof obj.ts === 'number' ? obj.ts : 0, + runId: typeof obj.runId === 'string' ? obj.runId : '', + ok: obj.ok === true, + keyId: typeof obj.keyId === 'string' ? obj.keyId : undefined, + issuerId: typeof obj.issuerId === 'string' ? obj.issuerId : undefined, + privateKey: typeof obj.privateKey === 'string' ? obj.privateKey : undefined, + errorCode: typeof obj.errorCode === 'string' ? obj.errorCode : undefined, + message: typeof obj.message === 'string' ? obj.message : undefined, + } + } + return null +} + +/** + * Incremental line splitter for a streamed stdout. Push raw chunks as they + * arrive; get back the protocol lines completed by that chunk. Partial trailing + * data is buffered until its newline arrives. Call {@link flush} at EOF to parse + * any final newline-less remainder. + */ +export class AscProtocolParser { + private buffer = '' + + push(chunk: string): AscProtocolLine[] { + this.buffer += chunk + const out: AscProtocolLine[] = [] + let newlineIndex = this.buffer.indexOf('\n') + while (newlineIndex !== -1) { + const rawLine = this.buffer.slice(0, newlineIndex) + this.buffer = this.buffer.slice(newlineIndex + 1) + const parsed = parseAscProtocolLine(rawLine) + if (parsed) + out.push(parsed) + newlineIndex = this.buffer.indexOf('\n') + } + return out + } + + flush(): AscProtocolLine[] { + const rest = this.buffer + this.buffer = '' + const parsed = parseAscProtocolLine(rest) + return parsed ? [parsed] : [] + } +} + +// Defensive: never let a value that looks like a secret reach analytics, even +// if a future helper version mistakenly puts one in event props. +const SECRET_KEY_PATTERN = /private[_-]?key|secret|p8|pem|password|token/i + +/** Coerce an arbitrary prop value to a PostHog-safe scalar, or drop it. */ +function coerceTagValue(value: unknown): string | number | boolean | undefined { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') + return value + if (value === null || value === undefined) + return undefined + // Flatten small structured values so they're still queryable. + try { + return JSON.stringify(value) + } + catch { + return undefined + } +} + +/** + * Build the `trackEvent` tags for a forwarded helper event: the helper's + * `props` (secret-stripped + scalar-coerced) plus protocol context. Exported + * for testing. + */ +export function buildEventTags(event: AscEventLine): Record { + const tags: Record = { + helper_event: event.name, + helper_run_id: event.runId, + helper_ts_ms: event.ts, + } + for (const [key, raw] of Object.entries(event.props)) { + if (SECRET_KEY_PATTERN.test(key)) + continue + const value = coerceTagValue(raw) + if (value !== undefined) + tags[`prop_${key}`] = value + } + return tags +} + +/** + * Map a helper event line to a `trackEvent` input. The `event` field is a + * human-readable Title Case rendering of the snake_case name (e.g. + * `step_changed` -> "Step Changed"), under the {@link ASC_KEY_CHANNEL} channel. + */ +export function ascEventToTrack(event: AscEventLine): { + channel: string + event: string + icon: string + tags: Record +} { + const humanName = event.name + .split('_') + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') + return { + channel: ASC_KEY_CHANNEL, + event: `ASC Key: ${humanName}`, + icon: '🔑', + tags: buildEventTags(event), + } +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 7cd524384e..62b7010281 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,8 @@ import { lastOutputCommand } from './build/last-output-command' import { checkBuildNeeded } from './build/needed' import type { OnboardingBuilderOptions } from './build/onboarding/command' import { onboardingBuilderCommand } from './build/onboarding/command' +import type { CreateAppleKeyOptions } from './build/onboarding/asc-key/command' +import { createAppleKeyCommand } from './build/onboarding/asc-key/command' import { requestBuildCommand } from './build/request' import { cleanupBundle } from './bundle/cleanup' import { checkCompatibility } from './bundle/compatibility' @@ -911,6 +913,24 @@ const buildCredentials = build iOS setup: https://capgo.app/docs/cli/cloud-build/ios/ Android setup: https://capgo.app/docs/cli/cloud-build/android/`) +buildCredentials + .command('apple-key') + .alias('asc-key') + .description(`Create an App Store Connect team API key with a guided macOS helper (macOS only). + +Opens a native window that walks you through Apple's App Store Connect UI in an +embedded browser, auto-captures the Issuer ID + Key ID, intercepts the one-time +.p8, validates it against Apple, and saves it to ~/.appstoreconnect/private_keys. +Progress statistics are forwarded to Capgo analytics (disable with CAPGO_DISABLE_TELEMETRY). + +Example: + npx @capgo/cli build credentials apple-key --appId com.example.app`) + .action((options: CreateAppleKeyOptions) => createAppleKeyCommand(options)) + .option('-a, --apikey ', optionDescriptions.apikey) + .option('--appId ', 'Save the captured key into this app iOS build credentials') + .option('--local', 'Save into the per-project .capgo-credentials.json instead of the global file') + .option('--json', 'Print the captured Key ID / Issuer ID / .p8 path as JSON') + buildCredentials .command('save') .description(`Save build credentials locally for iOS or Android. diff --git a/cli/test/test-asc-key-protocol.mjs b/cli/test/test-asc-key-protocol.mjs new file mode 100644 index 0000000000..f611da0436 --- /dev/null +++ b/cli/test/test-asc-key-protocol.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import { + ASC_KEY_CHANNEL, + ASC_PROTOCOL_VERSION, + AscProtocolParser, + ascEventToTrack, + buildEventTags, + parseAscProtocolLine, +} from '../src/build/onboarding/asc-key/protocol.ts' + +console.log('🧪 Testing asc-key stdout stats protocol...\n') + +// 1. parse a valid event line +{ + const line = parseAscProtocolLine('{"capgoAscKey":1,"kind":"event","ts":12,"runId":"r1","name":"step_changed","props":{"from":"login","to":"verifyAccess","elapsed_ms_on_prev":340}}') + assert.ok(line, 'event line should parse') + assert.equal(line.kind, 'event') + assert.equal(line.name, 'step_changed') + assert.equal(line.runId, 'r1') + assert.equal(line.props.to, 'verifyAccess') + console.log('✅ parses a valid event line') +} + +// 2. parse a valid success result line (credentials present) +{ + const line = parseAscProtocolLine('{"capgoAscKey":1,"kind":"result","ts":900,"runId":"r1","ok":true,"keyId":"ABC123","issuerId":"iss-uuid","privateKey":"-----BEGIN PRIVATE KEY-----\\nX\\n-----END PRIVATE KEY-----"}') + assert.ok(line, 'result line should parse') + assert.equal(line.kind, 'result') + assert.equal(line.ok, true) + assert.equal(line.keyId, 'ABC123') + assert.equal(line.issuerId, 'iss-uuid') + assert.ok(line.privateKey.includes('BEGIN PRIVATE KEY')) + console.log('✅ parses a success result line with credentials') +} + +// 3. parse a failure result line +{ + const line = parseAscProtocolLine('{"capgoAscKey":1,"kind":"result","ok":false,"errorCode":"USER_CANCELLED","message":"closed"}') + assert.ok(line) + assert.equal(line.ok, false) + assert.equal(line.errorCode, 'USER_CANCELLED') + console.log('✅ parses a failure result line') +} + +// 4. ignores non-protocol lines (chatter, wrong version, malformed, blank) +{ + assert.equal(parseAscProtocolLine(''), null) + assert.equal(parseAscProtocolLine('some swift NSLog chatter'), null) + assert.equal(parseAscProtocolLine('{"hello":"world"}'), null, 'no version tag => not protocol') + assert.equal(parseAscProtocolLine('{"capgoAscKey":999,"kind":"event","name":"x"}'), null, 'wrong version => ignored') + assert.equal(parseAscProtocolLine('{not valid json'), null) + console.log('✅ ignores chatter / wrong version / malformed / blank') +} + +// 5. streaming parser reassembles lines split across chunks +{ + const parser = new AscProtocolParser() + const a = parser.push('{"capgoAscKey":1,"kind":"event","name":"helper_started","props":{}}\n{"capgoAscKey":1,"kind":"ev') + assert.equal(a.length, 1, 'first complete line emitted, partial buffered') + assert.equal(a[0].name, 'helper_started') + const b = parser.push('ent","name":"signed_in","props":{"team_count":2}}\n') + assert.equal(b.length, 1, 'buffered partial completed by next chunk') + assert.equal(b[0].name, 'signed_in') + assert.equal(b[0].props.team_count, 2) + console.log('✅ streaming parser reassembles split lines') +} + +// 6. flush() returns a trailing newline-less line +{ + const parser = new AscProtocolParser() + const pushed = parser.push('{"capgoAscKey":1,"kind":"result","ok":true,"keyId":"K","issuerId":"I","privateKey":"P"}') + assert.equal(pushed.length, 0, 'no newline yet => nothing emitted') + const flushed = parser.flush() + assert.equal(flushed.length, 1) + assert.equal(flushed[0].keyId, 'K') + console.log('✅ flush() yields the final newline-less line') +} + +// 7. event -> trackEvent mapping uses the right channel + humanized name +{ + const event = { capgoAscKey: 1, kind: 'event', ts: 50, runId: 'r9', name: 'validation_succeeded', props: { duration_ms: 1200 } } + const mapped = ascEventToTrack(event) + assert.equal(mapped.channel, ASC_KEY_CHANNEL) + assert.equal(mapped.event, 'ASC Key: Validation Succeeded') + assert.equal(mapped.tags.helper_event, 'validation_succeeded') + assert.equal(mapped.tags.helper_run_id, 'r9') + assert.equal(mapped.tags.prop_duration_ms, 1200) + console.log('✅ ascEventToTrack maps channel + humanized name + tags') +} + +// 8. SECRET GUARD: a stray private key in event props must never reach tags +{ + const event = { capgoAscKey: 1, kind: 'event', ts: 1, runId: 'r', name: 'oops', props: { privateKey: 'SECRET', private_key: 'SECRET', p8: 'SECRET', token: 'SECRET', team_count: 3 } } + const tags = buildEventTags(event) + const serialized = JSON.stringify(tags) + assert.ok(!serialized.includes('SECRET'), 'no secret-looking value should appear in tags') + assert.equal(tags.prop_team_count, 3, 'non-secret props still pass through') + console.log('✅ secret-looking props are stripped before analytics') +} + +// 9. protocol version constant sanity +{ + assert.equal(ASC_PROTOCOL_VERSION, 1) + console.log('✅ protocol version constant') +} + +console.log('\n🎉 All asc-key protocol tests passed') From 7341f59b589613456db42782f260f9bf7ca960ac Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 12 Jun 2026 11:46:48 +0200 Subject: [PATCH 02/22] feat(cli): move ASC key helper into repo + wire it into iOS onboarding Move the native macOS Swift helper into the repo at cli/native/asc-key-helper (SwiftPM package, product P8Extract) so it ships and builds with the CLI instead of living in a separate repo. Wire it into the `build init` iOS credentials wizard as the .p8 entry fork: - New step p8-source-select: 'Do you already have a .p8 file?' - Yes -> existing import flows (unchanged). - No -> (macOS + helper present) p8-create-method-select. - p8-create-method-select: Automated (guided helper) vs Manual (create at Apple). - asc-key-generating: spawns the helper, forwards its stdout stats protocol to PostHog, captures keyId/issuerId/.p8, then converges on the existing verifying-key -> save flow. Locator now also finds a local `swift build` output of the vendored package (dev), and build-asc-key-helper.sh defaults to the in-repo package. tsc 0 errors, oxlint clean, asc-key protocol tests green, Swift package builds. --- cli/native/asc-key-helper/.gitignore | 4 + cli/native/asc-key-helper/Package.swift | 20 + cli/native/asc-key-helper/README.md | 29 + .../Sources/Models/ASCSession.swift | 92 + .../Sources/Models/FlowScripts.swift | 356 ++ .../Sources/Models/FlowStep.swift | 67 + .../Sources/Models/GuidedFlowModel.swift | 823 ++++ .../Sources/Models/KeyCredentials.swift | 54 + .../Sources/Models/StatsProtocol.swift | 84 + .../asc-key-helper/Sources/P8ExtractApp.swift | 34 + .../Sources/UI/ContentView.swift | 63 + .../Sources/UI/FlowDialogs.swift | 170 + .../Sources/UI/StepsPanel.swift | 455 ++ .../Sources/UI/TeamMonogram.swift | 32 + .../Sources/Util/P8FileLocator.swift | 37 + .../Sources/Validation/ASCKeyValidator.swift | 64 + .../Sources/Web/WebViewContainer.swift | 164 + .../asc-key-helper/THIRD-PARTY-LICENSES.md | 29 + cli/scripts/build-asc-key-helper.sh | 17 +- cli/src/build/onboarding/asc-key/helper.ts | 35 +- cli/src/build/onboarding/types.ts | 4 + cli/src/build/onboarding/ui/app.tsx | 3727 +++++++++-------- .../onboarding/ui/steps/ios-credentials.tsx | 67 + 23 files changed, 4603 insertions(+), 1824 deletions(-) create mode 100644 cli/native/asc-key-helper/.gitignore create mode 100644 cli/native/asc-key-helper/Package.swift create mode 100644 cli/native/asc-key-helper/README.md create mode 100644 cli/native/asc-key-helper/Sources/Models/ASCSession.swift create mode 100644 cli/native/asc-key-helper/Sources/Models/FlowScripts.swift create mode 100644 cli/native/asc-key-helper/Sources/Models/FlowStep.swift create mode 100644 cli/native/asc-key-helper/Sources/Models/GuidedFlowModel.swift create mode 100644 cli/native/asc-key-helper/Sources/Models/KeyCredentials.swift create mode 100644 cli/native/asc-key-helper/Sources/Models/StatsProtocol.swift create mode 100644 cli/native/asc-key-helper/Sources/P8ExtractApp.swift create mode 100644 cli/native/asc-key-helper/Sources/UI/ContentView.swift create mode 100644 cli/native/asc-key-helper/Sources/UI/FlowDialogs.swift create mode 100644 cli/native/asc-key-helper/Sources/UI/StepsPanel.swift create mode 100644 cli/native/asc-key-helper/Sources/UI/TeamMonogram.swift create mode 100644 cli/native/asc-key-helper/Sources/Util/P8FileLocator.swift create mode 100644 cli/native/asc-key-helper/Sources/Validation/ASCKeyValidator.swift create mode 100644 cli/native/asc-key-helper/Sources/Web/WebViewContainer.swift create mode 100644 cli/native/asc-key-helper/THIRD-PARTY-LICENSES.md diff --git a/cli/native/asc-key-helper/.gitignore b/cli/native/asc-key-helper/.gitignore new file mode 100644 index 0000000000..61ff060756 --- /dev/null +++ b/cli/native/asc-key-helper/.gitignore @@ -0,0 +1,4 @@ +.build/ +dist-helper/ +*.xcodeproj +.DS_Store diff --git a/cli/native/asc-key-helper/Package.swift b/cli/native/asc-key-helper/Package.swift new file mode 100644 index 0000000000..6f91e1fd69 --- /dev/null +++ b/cli/native/asc-key-helper/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// Capgo App Store Connect API-key helper — a native macOS app that guides the +// user through creating an App Store Connect *team* API key in an embedded +// browser and emits the result + a stats protocol on stdout (see +// Sources/Models/StatsProtocol.swift). The Capgo CLI spawns the precompiled +// binary; build it with cli/scripts/build-asc-key-helper.sh. +let package = Package( + name: "P8Extract", + platforms: [ + .macOS(.v14), + ], + targets: [ + .executableTarget( + name: "P8Extract", + path: "Sources" + ), + ] +) diff --git a/cli/native/asc-key-helper/README.md b/cli/native/asc-key-helper/README.md new file mode 100644 index 0000000000..884ce06cee --- /dev/null +++ b/cli/native/asc-key-helper/README.md @@ -0,0 +1,29 @@ +# Capgo App Store Connect API-key helper (macOS) + +A native macOS SwiftUI app that guides a user through creating an App Store +Connect **team** API key inside an embedded browser — auto-capturing the Issuer +ID + Key ID, intercepting the one-time `.p8`, validating it against Apple, and +saving it to `~/.appstoreconnect/private_keys/`. + +The Capgo CLI spawns the **precompiled** binary from the iOS build-credentials +onboarding (`build init`) when the user has no `.p8` yet, and from +`build credentials apple-key`. The helper streams a stdout **stats protocol** +(`Sources/Models/StatsProtocol.swift`) that the CLI forwards to PostHog; see +`../../src/build/onboarding/asc-key/PROTOCOL.md`. + +## Build + +```bash +# Universal (arm64 + x86_64) release binary: +../../scripts/build-asc-key-helper.sh + +# Or directly with SwiftPM (single arch, for dev): +swift build -c release +# → .build/release/P8Extract +``` + +The binary is **not** shipped in the npm tarball (macOS-only). The CLI locates +it via `CAPGO_ASC_KEY_HELPER_PATH`, the `~/.capgo/asc-key-helper/` cache, or a +local `.build/` product during development. + +Portions adapted from AppStoreConnectKit (MIT) — see `THIRD-PARTY-LICENSES.md`. diff --git a/cli/native/asc-key-helper/Sources/Models/ASCSession.swift b/cli/native/asc-key-helper/Sources/Models/ASCSession.swift new file mode 100644 index 0000000000..cc541ca584 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/ASCSession.swift @@ -0,0 +1,92 @@ +import Foundation + +/// A team (provider) the signed-in user belongs to. +struct ASCTeam: Identifiable, Equatable { + /// Numeric providerId — stable identity, used to compare/verify. + let id: String + /// publicProviderId (UUID) — required by the providerSwitchRequests API. + let publicId: String? + let name: String +} + +/// The signed-in App Store Connect session, as reported by the same +/// `/olympus/v1/session` endpoint the ASC web app uses. +struct ASCSession: Equatable { + let currentTeam: ASCTeam + let teams: [ASCTeam] + let email: String + let role: String? + /// Raw role strings for the current team (e.g. ["DEVELOPER", "ADMIN"]). + let roles: [String] + /// Per-team feature flags (e.g. contains "apiKeys" when the team has the + /// App Store Connect API feature enabled). + let featureFlags: [String] + + var otherTeams: [ASCTeam] { + teams.filter { $0.id != currentTeam.id } + } + + /// The team has turned on the App Store Connect API (the Account Holder + /// did the one-time "Request Access" under Integrations). + var teamHasApiEnabled: Bool { featureFlags.contains("apiKeys") } + + /// The user holds a role allowed to generate team keys. + var userCanGenerateKeys: Bool { + roles.contains { $0 == "ADMIN" || $0 == "ACCOUNT_HOLDER" } + } + + /// Both gates must pass to create a team API key here. + var canCreateKeys: Bool { teamHasApiEnabled && userCanGenerateKeys } + + /// Defensive parse — Apple doesn't document this payload, so missing + /// fields degrade to a smaller session instead of a failure. + static func parse(jsonText: String) -> ASCSession? { + guard let data = jsonText.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let provider = root["provider"] as? [String: Any], + let current = team(from: provider) else { + return nil + } + let available = (root["availableProviders"] as? [[String: Any]] ?? []) + .compactMap { team(from: $0) } + let user = root["user"] as? [String: Any] + let email = (user?["emailAddress"] as? String) ?? "" + // Roles live at the root of the session payload (e.g. ["DEVELOPER", + // "CIPS", "ADMIN"]) and mix real roles with plumbing flags. + let roles = (root["roles"] as? [String]) ?? (user?["roles"] as? [String]) ?? [] + let featureFlags = (root["featureFlags"] as? [String]) ?? [] + return ASCSession( + currentTeam: current, + teams: available.isEmpty ? [current] : available, + email: email, + role: primaryRole(from: roles), + roles: roles, + featureFlags: featureFlags + ) + } + + /// Pick the most privileged human-meaningful role; ignore plumbing + /// entries like CIPS or CLOUD_MANAGED_APP_DISTRIBUTION. + private static func primaryRole(from roles: [String]) -> String? { + let ranking = [ + "ACCOUNT_HOLDER", "ADMIN", "APP_MANAGER", "DEVELOPER", + "MARKETING", "FINANCE", "SALES", "CUSTOMER_SUPPORT", + ] + return ranking.first(where: roles.contains).map(prettyRole) + } + + private static func team(from object: [String: Any]) -> ASCTeam? { + guard let name = object["name"] as? String else { return nil } + let numericId = (object["providerId"] as? Int).map(String.init) + ?? (object["providerId"] as? String) + let publicId = object["publicProviderId"] as? String + guard let id = numericId ?? publicId else { return nil } + return ASCTeam(id: id, publicId: publicId, name: name) + } + + private static func prettyRole(_ raw: String) -> String { + raw.split(separator: "_") + .map { $0.capitalized } + .joined(separator: " ") + } +} diff --git a/cli/native/asc-key-helper/Sources/Models/FlowScripts.swift b/cli/native/asc-key-helper/Sources/Models/FlowScripts.swift new file mode 100644 index 0000000000..f95faaa8ff --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/FlowScripts.swift @@ -0,0 +1,356 @@ +import Foundation + +/// JavaScript run inside the App Store Connect page for detection, +/// highlighting and value scraping. +/// XPath detectors and styling approach adapted from AppStoreConnectKit +/// (https://github.com/MortenGregersen/AppStoreConnectKit), +/// MIT License, © Morten Bjerg Gregersen. See THIRD-PARTY-LICENSES.md. +enum FlowScripts { + // MARK: - Shared snippets + + /// Wait (bounded) for the page's loading spinners to disappear. + static let awaitNoProgressBar = """ + const __settleStart = performance.now(); + while (document.querySelectorAll('[role="progressbar"]').length > 0 && performance.now() - __settleStart < 8000) { + await new Promise(r => setTimeout(r, 200)); + } + """ + + static let findTeamIssuerId = """ + const issuerId = document.evaluate( + './/span[normalize-space()="Issuer ID"]/following::span[@role="presentation"][1]', + document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null + ).singleNodeValue; + """ + + static let findGenerateButton = """ + const generateButton = document.evaluate( + './/h3[starts-with(normalize-space(), "Active")]/following-sibling::button[1]', + document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null + ).singleNodeValue; + """ + + static let findNewKeyRow = """ + const downloadButton = document.evaluate( + './/button[normalize-space()="Download"]', + document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null + ).singleNodeValue; + const keyIdElement = document.evaluate( + './/button[normalize-space()="Download"]/ancestor::*[@role="row"]//p', + document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null + ).singleNodeValue; + """ + + // MARK: - Read scripts (return values to Swift) + + static let readIssuerId = """ + \(awaitNoProgressBar) + \(findTeamIssuerId) + return issuerId ? issuerId.textContent.trim() : null; + """ + + static let readNewKeyId = """ + \(awaitNoProgressBar) + \(findNewKeyRow) + return keyIdElement ? keyIdElement.textContent.trim() : null; + """ + + static let hasGenerateButton = """ + \(awaitNoProgressBar) + \(findGenerateButton) + return !!generateButton; + """ + + static let hasDownloadButton = """ + \(awaitNoProgressBar) + \(findNewKeyRow) + return !!downloadButton; + """ + + /// List the people who can act on an access request: the Account Holder + /// (can enable + create) and Admins (can create). Returns JSON + /// [{name, email, isAccountHolder, isAdmin}]. + static let readEligibleContacts = """ + const url = '/iris/v1/users?include=provider&limit=500&fields[users]=email,firstName,lastName,roles,username'; + const r = await fetch(url, { headers: { 'Accept': 'application/json' } }); + if (!r.ok) { return '[]'; } + const j = await r.json(); + const out = (j.data || []).map(u => { + const a = u.attributes || {}; + const roles = a.roles || []; + return { + name: ((a.firstName || '') + ' ' + (a.lastName || '')).trim(), + email: a.email || a.username || '', + isAccountHolder: roles.indexOf('ACCOUNT_HOLDER') !== -1, + isAdmin: roles.indexOf('ADMIN') !== -1 + }; + }).filter(u => u.email && (u.isAccountHolder || u.isAdmin)); + return JSON.stringify(out); + """ + + /// Authoritative team-enablement check: /iris/v1/apiAccesses holds the + /// "Request Access" record. A non-empty data array means the Account + /// Holder has enabled the App Store Connect API for this team. Returns + /// "enabled" / "disabled" / "unknown" (on error). Works for any role. + static let readApiAccessEnabled = """ + const r = await fetch('/iris/v1/apiAccesses', { headers: { 'Accept': 'application/json' } }); + if (!r.ok) { return 'unknown'; } + const j = await r.json(); + return ((j.data || []).length > 0) ? 'enabled' : 'disabled'; + """ + + /// Fetch the App Store Connect session (current team, available teams, + /// signed-in user) via the same-origin endpoint the ASC web app itself + /// uses. Returns the raw JSON text, or null when not signed in. + static let readSession = """ + const response = await fetch('/olympus/v1/session', { headers: { 'Accept': 'application/json' } }); + if (!response.ok) { return null; } + return await response.text(); + """ + + /// Switch the active team (provider). Returns true on success. + /// Mirrors fastlane Spaceship: the POST /olympus/v1/session needs the + /// `csrf` and `csrf_ts` tokens echoed from a prior session response, + /// otherwise Apple rejects it. + /// Switch the active team by driving Apple's real account-menu switcher. + /// The raw providerSwitchRequests API returns 201 but never commits — the + /// commit is in-memory SPA orchestration we can't replay — so we click the + /// menu the SPA renders (inside amp-nav's shadow root) and let Apple's own + /// code do the switch + navigation. Matched by team name, not CSS classes. + /// Returns a JSON diagnostics string. + static func switchTeamViaMenuScript(teamName: String) -> String { + let safe = teamName + .replacingOccurrences(of: "\\", with: "") + .replacingOccurrences(of: "'", with: "\\'") + return """ + const out = {}; + try { + const nav = document.querySelector('amp-nav'); + if (!nav || !nav.shadowRoot) { out.error = 'no amp-nav shadowRoot'; return JSON.stringify(out); } + const root = nav.shadowRoot; + const sleep = ms => new Promise(r => setTimeout(r, ms)); + // Open the account menu (profile trigger is labelled "Account name menu"). + const trigger = [...root.querySelectorAll('*')].find(e => + (e.textContent || '').includes('Account name menu') && e.children.length <= 3); + out.foundTrigger = !!trigger; + if (trigger) (trigger.closest('button,[role=button],a') || trigger).click(); + // Wait for the team rows to render, then click the matching one. + let target = null; + for (let i = 0; i < 40; i++) { + const lis = [...root.querySelectorAll('li')]; + target = lis.find(li => li.textContent.trim() === '\(safe)'); + if (target) break; + await sleep(50); + } + out.foundTarget = !!target; + if (!target) { out.error = 'team row not found in menu'; return JSON.stringify(out); } + (target.querySelector('a,button,[role=button]') || target).click(); + out.clicked = true; + } catch (e) { + out.error = String(e); + } + return JSON.stringify(out); + """ + } + + /// Best-effort scrape of the active team keys table. + /// Returns a JSON string: [{"name": "...", "keyId": "..."}]. + static let readExistingKeys = #""" + \#(awaitNoProgressBar) + const rows = Array.from(document.querySelectorAll('[role="row"]')); + const keys = []; + const keyIdPattern = /^[A-Z0-9]{8,14}$/; + for (const row of rows) { + const cells = Array.from(row.querySelectorAll('[role="cell"], td, [role="gridcell"]')); + const texts = cells.map(c => c.textContent.trim()).filter(t => t.length > 0); + const keyId = texts.find(t => keyIdPattern.test(t)); + if (keyId) { + keys.push({ name: texts[0] === keyId ? "(unnamed)" : texts[0], keyId: keyId }); + } + } + return JSON.stringify(keys); + """# + + // MARK: - Generate API Key dialog + + /// The role a Capgo Builder key should have. Admin has full access. + static let recommendedRole = "Admin" + + /// The dialog's key-name input (id="name", no type attribute) and role + /// input (name="roles", placeholder="Select Roles" — NOT an aria-label). + private static let findNameInput = """ + const nameInput = document.querySelector('#name, input[name="name"]'); + """ + + static let isGenerateDialogOpen = """ + const generateBtn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Generate'); + const roleInput = document.querySelector('input[name="roles"]'); + return !!(generateBtn && roleInput); + """ + + static let readNameFilled = """ + \(findNameInput) + return !!(nameInput && nameInput.value.trim().length > 0); + """ + + /// Generate is enabled only when both a name and a role are set. Since the + /// name comes first, an enabled Generate means a role has been chosen. + static let isGenerateEnabled = """ + const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Generate'); + return !!(btn && !btn.disabled && btn.getAttribute('aria-disabled') !== 'true'); + """ + + /// Read ONLY the selected role chips inside the Access field — explicitly + /// excluding the dropdown's menuitem options (which appear/disappear and + /// would otherwise make the step flip-flop between role and generate). + static let readSelectedRoles = """ + const roleRe = /^(Admin|App Manager|Developer|Finance|Sales and Reports|Customer Support|Marketing)$/; + const scope = document.querySelector('#roles'); + if (!scope) return '[]'; + const found = [...scope.querySelectorAll('*')].filter(e => + e.children.length === 0 && + roleRe.test(e.textContent.trim()) && + !e.closest('[role=menuitem]') && !e.closest('[role=listbox]') && !e.closest('[role=option]')); + return JSON.stringify([...new Set(found.map(e => e.textContent.trim()))]); + """ + + static let autofillName = """ + \(findNameInput) + if (!nameInput) return false; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; + setter.call(nameInput, 'Capgo Builder'); + nameInput.dispatchEvent(new Event('input', { bubbles: true })); + nameInput.dispatchEvent(new Event('change', { bubbles: true })); + return true; + """ + + static func autofillRoleScript(role: String) -> String { + let safe = role.replacingOccurrences(of: "'", with: "") + return """ + const roleInput = document.querySelector('input[name="roles"]'); + if (!roleInput) return false; + roleInput.click(); roleInput.focus(); + const sleep = ms => new Promise(r => setTimeout(r, ms)); + for (let i = 0; i < 20; i++) { + // Prefer the interactive button/menuitem; exclude the keys table. + const opt = [...document.querySelectorAll('button, [role=menuitem]')] + .find(e => e.textContent.trim() === '\(safe)' && !e.closest('table')); + if (opt) { opt.click(); return true; } + await sleep(50); + } + return false; + """ + } + + // MARK: - Highlighting + + // We never style App Store Connect's own elements: mutating React-owned + // nodes corrupts its reconciler and throws "removeChild must be an instance + // of Node", which crashes the page (visible as the redux "mw dispatch" + // error) and wipes our styling on the next re-render. Instead we float a + // single overlay
of OUR OWN, appended to (outside React's + // root), and reposition it each frame from the target's bounding box. + // React never reconciles it, can't wipe it, and we never touch a node it + // owns — so it survives the dropdown's re-renders without crashing the page. + + /// Float a native overlay over each element returned by a `finder` (a JS + /// body containing a `return`). One overlay
per target, all tracked + /// by a single animation-frame loop so they follow their targets through + /// React re-renders and scrolling. A target whose element isn't currently + /// on screen simply hides its overlay (e.g. the Admin option before the + /// dropdown is opened). `scroll` brings the FIRST target into view once. + private static func overlayHighlight(_ targets: [(finder: String, pad: Int)], scroll: Bool = false) -> String { + let specs = targets + .map { "{ find: () => { \($0.finder) }, pad: \($0.pad) }" } + .joined(separator: ", ") + let scrollJS = scroll && !targets.isEmpty + ? "const __p8s = (() => { \(targets[0].finder) })(); if (__p8s && __p8s.scrollIntoView) __p8s.scrollIntoView({ behavior: 'smooth', block: 'center' });" + : "" + return """ + window.__p8specs = [\(specs)]; + \(scrollJS) + if (window.__p8raf) cancelAnimationFrame(window.__p8raf); + const __p8style = 'position:fixed;border:5px solid #ff3b30;border-radius:14px;pointer-events:none;z-index:2147483647;box-shadow:0 0 0 6px rgba(255,59,48,0.35), 0 0 18px 4px rgba(255,59,48,0.45);display:none'; + const __p8tick = () => { + window.__p8specs.forEach((spec, i) => { + let ov = document.getElementById('__p8overlay' + i); + if (!ov) { + ov = document.createElement('div'); + ov.id = '__p8overlay' + i; + ov.className = '__p8ov'; + ov.style.cssText = __p8style; + document.body.appendChild(ov); + } + const t = spec.find(); + if (t && t.getClientRects().length) { + const r = t.getBoundingClientRect(); + ov.style.display = 'block'; + ov.style.left = (r.left - spec.pad) + 'px'; + ov.style.top = (r.top - spec.pad) + 'px'; + ov.style.width = (r.width + spec.pad * 2) + 'px'; + ov.style.height = (r.height + spec.pad * 2) + 'px'; + } else { + ov.style.display = 'none'; + } + }); + window.__p8raf = requestAnimationFrame(__p8tick); + }; + __p8tick(); + """ + } + + /// Single-target convenience. + private static func overlayHighlight(finder: String, scroll: Bool = false, pad: Int = 10) -> String { + overlayHighlight([(finder: finder, pad: pad)], scroll: scroll) + } + + /// Tear down all overlays and the tracking loop. + static let removeOverlay = """ + if (window.__p8raf) { cancelAnimationFrame(window.__p8raf); window.__p8raf = null; } + document.querySelectorAll('.__p8ov').forEach(e => e.remove()); + window.__p8specs = []; + """ + + static func highlightScript(for step: FlowStep) -> String? { + switch step { + case .createKey: + """ + \(awaitNoProgressBar) + \(overlayHighlight(finder: "\(findGenerateButton) return generateButton;", scroll: true, pad: 16)) + """ + case .nameKey: + overlayHighlight(finder: "return document.querySelector('#name, input[name=\"name\"]');") + case .selectRole: + // Two overlays: the Access field, and the Admin option (which only + // exists once the dropdown is open — its overlay stays hidden until + // then). Read-only getBoundingClientRect; we never touch the nodes. + overlayHighlight([ + (finder: "return document.querySelector('input[name=\"roles\"]');", pad: 10), + (finder: "return [...document.querySelectorAll('[role=menuitem], [role=option], li')].find(e => (e.textContent || '').trim() === '\(recommendedRole)' && !e.closest('table'));", pad: 8), + ]) + case .generateKey: + overlayHighlight(finder: "return [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Generate');") + case .downloadKey: + // Prefer the modal's confirm "Download" button once the + // "Download API Key" dialog is open (the real last click); fall back + // to the row's Download button before the dialog appears. The modal + // button lives outside the keys table/rows, so we match a "Download" + // button that is NOT inside a [role=row] or . + """ + \(awaitNoProgressBar) + \(overlayHighlight(finder: "var __m = [...document.querySelectorAll('button')].find(b => (b.textContent || '').trim() === 'Download' && !b.closest('[role=\"row\"]') && !b.closest('table')); if (__m) return __m; \(findNewKeyRow) return downloadButton;", scroll: true, pad: 12)) + """ + default: + nil + } + } + + static func unhighlightScript(for step: FlowStep) -> String? { + switch step { + case .createKey, .nameKey, .selectRole, .generateKey, .downloadKey: + removeOverlay + default: + nil + } + } +} diff --git a/cli/native/asc-key-helper/Sources/Models/FlowStep.swift b/cli/native/asc-key-helper/Sources/Models/FlowStep.swift new file mode 100644 index 0000000000..8f17db8279 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/FlowStep.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Steps for obtaining a TEAM App Store Connect API key. +/// Adapted from AppStoreConnectKit (https://github.com/MortenGregersen/AppStoreConnectKit), +/// MIT License, © Morten Bjerg Gregersen. See THIRD-PARTY-LICENSES.md. +enum FlowStep: Int, CaseIterable, Comparable, Hashable { + case login + case selectTeam + case verifyAccess + case captureIssuerId + case createKey + case nameKey + case selectRole + case generateKey + case captureKeyId + case downloadKey + /// Existing-key path only: the P8 cannot be re-downloaded, the user must + /// provide the file they saved when the key was created. + case locateP8File + + static func < (lhs: FlowStep, rhs: FlowStep) -> Bool { + lhs.rawValue < rhs.rawValue + } + + var instruction: String { + switch self { + case .login: "Sign in to App Store Connect" + case .selectTeam: "Confirm your team" + case .verifyAccess: "Check API access" + case .captureIssuerId: "Issuer ID is captured" + case .createKey: "Open the Generate dialog" + case .nameKey: "Name the key" + case .selectRole: "Set the role to Admin" + case .generateKey: "Click “Generate”" + case .captureKeyId: "Key ID is captured" + case .downloadKey: "Download the API key" + case .locateP8File: "Locate your existing .p8 file" + } + } + + var detail: String? { + switch self { + case .login: + "Passkey sign-in isn’t supported here — use your Apple ID password and a verification code." + case .selectTeam: + "API keys belong to a team — pick the right one in the dialog. A key can’t be moved to another team later." + case .verifyAccess: + "Confirming this team has the App Store Connect API enabled and that your role can create a key." + case .captureIssuerId: + "We take you straight to the API keys page and read it automatically. You can also paste it below." + case .createKey: + "Click the highlighted “+” to open the Generate API Key dialog." + case .nameKey: + "Give the key a name (the field is outlined). Use the button to fill in “Capgo Builder”, or type your own." + case .selectRole: + "Set Access to Admin (the field is outlined). Use the button to pick it automatically." + case .generateKey: + "Click the highlighted “Generate” to create the key." + case .captureKeyId: + "Your new key’s ID is read automatically from its row — nothing to do." + case .downloadKey: + "Click the highlighted “Download API Key”. Apple allows this only once — we capture the key directly, so just click Download." + case .locateP8File: + "Apple doesn’t allow re-downloading a key. Select the AuthKey file you saved when this key was created." + } + } +} diff --git a/cli/native/asc-key-helper/Sources/Models/GuidedFlowModel.swift b/cli/native/asc-key-helper/Sources/Models/GuidedFlowModel.swift new file mode 100644 index 0000000000..49927436ed --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/GuidedFlowModel.swift @@ -0,0 +1,823 @@ +import AppKit +import Foundation +import Observation +import WebKit + +/// Someone on the team who can act on an access request. +struct TeamContact: Decodable, Equatable { + let name: String + let email: String + let isAccountHolder: Bool + let isAdmin: Bool +} + +/// A team API key that already exists in the user's account. +struct ExistingKey: Identifiable, Equatable, Decodable { + let name: String + let keyId: String + var id: String { keyId } +} + +enum FlowMode: Equatable { + case createNew + case useExisting(ExistingKey) +} + +enum StepState { + case done, current, upcoming +} + +/// Why the user can't create a key on the current team. +enum AccessDeniedReason { + case notEnabled // team never did "Request Access" + case insufficientRole // API on, but the user isn't Admin/Account Holder +} + +/// Drives the guided team-API-key flow: watches the page, advances steps, +/// scrapes values, and hands off to validation + emission. +/// Step-resolution structure adapted from AppStoreConnectKit +/// (https://github.com/MortenGregersen/AppStoreConnectKit), +/// MIT License, © Morten Bjerg Gregersen. See THIRD-PARTY-LICENSES.md. +@MainActor @Observable +final class GuidedFlowModel { + static let apiKeysURLString = "https://appstoreconnect.apple.com/access/integrations/api" + static let apiKeysURL = URL(string: apiKeysURLString)! + /// The root app — navigating here forces a fresh provider-context read, + /// which is how the frontend lands a team switch. + static let appsURL = URL(string: "https://appstoreconnect.apple.com/apps")! + + // Wiring, set by WebViewContainer.Coordinator. + var callJavaScript: @MainActor (String) async throws -> Any? = { _ in nil } + weak var webView: WKWebView? + + // Flow state. + private(set) var currentStep: FlowStep = .login + private(set) var mode: FlowMode = .createNew + var issuerId: String = "" + var keyId: String = "" + private(set) var privateKey: String = "" + private(set) var existingKeys: [ExistingKey] = [] + private(set) var session: ASCSession? + private(set) var teamConfirmed = false + private(set) var currentURL: String = GuidedFlowModel.apiKeysURLString + private(set) var scrapeTroubleWarning = false + private(set) var statusMessage: String? + private(set) var autoLocateMessage: String? + private(set) var isValidating = false + private(set) var validationError: String? + /// The current team has no App Store Connect API access (Apple shows a + /// "Request Access" page instead of the keys UI). + private(set) var apiAccessDenied = false + private(set) var accessDeniedReason: AccessDeniedReason? + /// Admins / Account Holder the user can email about access (fetched lazily). + private(set) var eligibleContacts: [TeamContact] = [] + /// Generate-dialog state, for the name/role guided sub-steps. + private(set) var dialogNameFilled = false + private(set) var selectedRoles: [String] = [] + /// Snapshot of the web view, blurred behind the team-confirmation dialog. + private(set) var webSnapshot: NSImage? + + private var apiAccessWarningDismissed = false + private var apiEnabledChecked = false + private var apiEnabledResult: Bool? + private var everLoggedIn = false + private var recoverAttempted = false + private var expectedTeamId: String? + private var awaitingSwitchApply = false + private var currentURLValue: URL? + private var pollTask: Task? + private var didAttemptValidation = false + private var didAutoNavigate = false + private var isResolving = false + private var pageSettled = false + private var steeredToKeys = false + private var issuerScrapeFailures = 0 + private let validator = ASCKeyValidator() + // Stats-protocol bookkeeping (see StatsProtocol). One-shot guards keep the + // polled resolve loop from re-emitting the same milestone every tick. + private var stepEnteredAt = Date() + private var didEmitSignedIn = false + private var didEmitCapability = false + private var didEmitAccessDenied = false + + // MARK: - Steps + + var steps: [FlowStep] { + // Single-team accounts (and pre-login, when most users will turn out + // to be single-team) skip the confirmation step entirely. + let confirmStep: [FlowStep] = (session?.teams.count ?? 1) > 1 ? [.selectTeam] : [] + switch mode { + case .createNew: + return [.login] + confirmStep + [.verifyAccess, .captureIssuerId, .createKey, .nameKey, .selectRole, .generateKey, .captureKeyId, .downloadKey] + case .useExisting: + return [.login] + confirmStep + [.verifyAccess, .captureIssuerId, .locateP8File] + } + } + + func state(of step: FlowStep) -> StepState { + guard let currentIndex = steps.firstIndex(of: currentStep), + let stepIndex = steps.firstIndex(of: step) else { + return .upcoming + } + if stepIndex < currentIndex { return .done } + if stepIndex == currentIndex { return .current } + return .upcoming + } + + var currentStepNumber: Int { + (steps.firstIndex(of: currentStep) ?? 0) + 1 + } + + /// Multi-team accounts must explicitly confirm which team gets the key — + /// the Issuer ID and all keys are per-team. + var needsTeamConfirmation: Bool { + guard let session else { return false } + return session.teams.count > 1 && !teamConfirmed + } + + var showIssuerField: Bool { + !issuerId.isEmpty || state(of: .captureIssuerId) != .upcoming + } + + var showKeyIdField: Bool { + if case .useExisting = mode { return true } + return !keyId.isEmpty || state(of: .captureKeyId) != .upcoming + } + + var showExistingKeysSection: Bool { + guard case .createNew = mode else { return false } + return !existingKeys.isEmpty && privateKey.isEmpty && currentStep <= .createKey + } + + // MARK: - Generate dialog (name + role) guidance + + /// A role other than the recommended one is currently selected. + var wrongRoleSelected: Bool { + !selectedRoles.isEmpty && !selectedRoles.contains(FlowScripts.recommendedRole) + } + + var canAutofillName: Bool { currentStep == .nameKey && !dialogNameFilled } + var canAutofillRole: Bool { currentStep == .selectRole && selectedRoles.isEmpty } + + func autofillKeyName() { + Task { _ = try? await callJavaScript(FlowScripts.autofillName) } + } + + func autofillKeyRole() { + Task { _ = try? await callJavaScript(FlowScripts.autofillRoleScript(role: FlowScripts.recommendedRole)) } + } + + /// True when the user navigated somewhere that isn't part of the flow + /// (another site, or an unrelated App Store Connect page). + var isOffCourse: Bool { + guard let url = currentURLValue, let host = url.host else { return false } + let authHosts = ["idmsa.apple.com", "appleid.apple.com", "account.apple.com"] + if authHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) { + return false + } + guard host == "appstoreconnect.apple.com" else { return true } + // Wrong ASC page only counts once auto-navigation had its chance — + // right after login we redirect to the keys page ourselves. + guard didAutoNavigate else { return false } + let path = url.path + let onCourse = path.isEmpty || path == "/" || path.hasPrefix("/login") || path.hasPrefix("/access") + return !onCourse + } + + /// "Take me back" — jump straight to the API keys page. + func goToKeyPage() { + statusMessage = nil + webView?.load(URLRequest(url: Self.apiKeysURL)) + } + + // MARK: - Teams + + /// Explicit choice from the confirmation dialog. Confirmation is applied + /// optimistically; if the switch lands on a different team than chosen, + /// the session refetch reopens the dialog. + func confirmTeamSelection(_ team: ASCTeam) { + teamConfirmed = true + statusMessage = nil + webSnapshot = nil + let isSwitch = team.id != session?.currentTeam.id + FileHandle.standardError.write(Data("[confirmTeam] \(team.name) isSwitch=\(isSwitch) current=\(session?.currentTeam.id ?? "nil")\n".utf8)) + StatsProtocol.event("team_confirmed", [ + "is_switch": isSwitch, + "team_count": session?.teams.count ?? 1, + ]) + guard isSwitch else { return } + switchTeam(to: team) + } + + /// From the no-API-access dialog: pick a different team. + func reopenTeamChoice() { + apiAccessDenied = false + teamConfirmed = false + // Reopening happens off the keys page (e.g. /access/users), where + // resolveOnApiPage won't run to capture the blur — grab it here. + webSnapshot = nil + captureWebSnapshotIfNeeded() + setStep(.selectTeam) + } + + private func captureWebSnapshotIfNeeded() { + guard webSnapshot == nil, let webView else { return } + webView.takeSnapshot(with: nil) { [weak self] image, _ in + Task { @MainActor in + self?.webSnapshot = image + } + } + } + + func dismissApiAccessWarning() { + apiAccessDenied = false + apiAccessWarningDismissed = true + webSnapshot = nil + } + + /// Keeps a persistent note in the status bar after the warning is dismissed. + var showsApiAccessNote: Bool { + apiAccessWarningDismissed && issuerId.isEmpty + } + + func switchTeam(to team: ASCTeam) { + // The raw switch API never commits — drive Apple's real account-menu + // switcher and let its own code do the switch + navigation. We watch + // the session to confirm, and inform the user if it doesn't land. + expectedTeamId = team.id + statusMessage = "Switching to \(team.name)…" + resetCapturedState() + teamConfirmed = true + session = nil + awaitingSwitchApply = true + steeredToKeys = false + Task { + let script = FlowScripts.switchTeamViaMenuScript(teamName: team.name) + let diagnostics = (try? await callJavaScript(script)) as? String + FileHandle.standardError.write(Data("[switch] \(team.name) -> \(diagnostics ?? "")\n".utf8)) + guard parseClicked(diagnostics) else { + switchFailed(team) + return + } + // Apple's click triggers the real switch + navigation; resolve() + // routes the resulting /apps load to the keys page, and the + // post-reload session confirms the team (via expectedTeamId). + // Safety net: if nothing lands in time, inform the user. + try? await Task.sleep(for: .seconds(12)) + if expectedTeamId == team.id { + switchFailed(team) + } + } + } + + private func parseClicked(_ diagnostics: String?) -> Bool { + guard let data = diagnostics?.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return root["clicked"] as? Bool == true + } + + /// The auto-switch didn't land — keep the user moving and tell them to do + /// it themselves; a manual menu switch (which navigates) is detected too. + private func switchFailed(_ team: ASCTeam) { + expectedTeamId = nil + awaitingSwitchApply = false + teamConfirmed = true + session = nil + didAutoNavigate = false // re-detect a manual switch's navigation + statusMessage = "Couldn’t switch to \(team.name) automatically — open the account menu (top-right) and pick the team yourself; we’ll continue once you do." + } + + /// Everything scraped so far belongs to the previous team. + private func resetCapturedState() { + issuerId = "" + keyId = "" + privateKey = "" + existingKeys = [] + didAttemptValidation = false + validationError = nil + autoLocateMessage = nil + scrapeTroubleWarning = false + issuerScrapeFailures = 0 + apiAccessDenied = false + apiAccessWarningDismissed = false + accessDeniedReason = nil + apiEnabledChecked = false + apiEnabledResult = nil + eligibleContacts = [] + dialogNameFilled = false + selectedRoles = [] + if case .useExisting = mode { + mode = .createNew + } + } + + /// Authoritative team-enablement check via /iris/v1/apiAccesses. + /// Returns nil if the endpoint couldn't be read (caller falls back). + private func checkApiEnabled() async -> Bool? { + guard let result = (try? await callJavaScript(FlowScripts.readApiAccessEnabled)) as? String else { + return nil + } + switch result { + case "enabled": return true + case "disabled": return false + default: return nil + } + } + + private func fetchSessionIfNeeded() async { + guard session == nil else { return } + guard let jsonText = (try? await callJavaScript(FlowScripts.readSession)) as? String, + let parsed = ASCSession.parse(jsonText: jsonText) else { + return + } + session = parsed + } + + /// After the menu switch, the session flips server-side but Apple often + /// navigates somewhere other than the keys page (e.g. /access/users). Read + /// the session to confirm the new team, then make sure we end on the keys + /// page — only there does the permission check + scrape run. + private func applySwitch(urlString: String) async { + guard let target = expectedTeamId else { awaitingSwitchApply = false; return } + guard let json = (try? await callJavaScript(FlowScripts.readSession)) as? String, + let parsed = ASCSession.parse(jsonText: json), + parsed.currentTeam.id == target else { + return // switch hasn't landed yet — keep waiting + } + session = parsed + teamConfirmed = true + statusMessage = "Switched to \(parsed.currentTeam.name)." + if urlString.hasPrefix(Self.apiKeysURLString) { + // We're on the keys page under the new team — done; normal flow + // (permission check + scrape) resumes on the next pass. + expectedTeamId = nil + awaitingSwitchApply = false + steeredToKeys = false + FileHandle.standardError.write(Data("[applySwitch] landed on keys page, team=\(parsed.currentTeam.id)\n".utf8)) + } else if !steeredToKeys { + // Apple parked us elsewhere (e.g. /access/users, often via SPA + // routing with no didFinish, so we can't wait on pageSettled). + // The switch already committed server-side, so navigating now is + // safe; steeredToKeys (reset on each urlChanged) prevents spamming. + steeredToKeys = true + FileHandle.standardError.write(Data("[applySwitch] team=\(parsed.currentTeam.id) on \(urlString) — steering to keys\n".utf8)) + webView?.load(URLRequest(url: Self.apiKeysURL)) + } + } + + // MARK: - Page events (from Coordinator) + + func urlChanged(_ url: URL) { + currentURLValue = url + currentURL = url.absoluteString + pageSettled = false + steeredToKeys = false + ensurePolling() + // React immediately instead of waiting for the next poll tick. + Task { await resolveNow() } + } + + func pageDidFinishLoading() { + pageSettled = true + Task { + await highlightCurrentStep() + await resolveNow() + } + } + + /// Run a resolve pass now, guarded against overlap with the poll loop. + private func resolveNow() async { + guard !isResolving, !isValidating, let url = currentURLValue else { return } + isResolving = true + await resolve(url: url) + isResolving = false + } + + func privateKeyCaptured(_ pem: String, replace: Bool = false) { + guard replace || privateKey.isEmpty else { return } + privateKey = pem + didAttemptValidation = false + statusMessage = keyId.isEmpty + ? "Private key captured — waiting for the Key ID…" + : "Private key captured." + } + + // MARK: - Polling loop + + private func ensurePolling() { + guard pollTask == nil else { return } + pollTask = Task { [weak self] in + while !Task.isCancelled { + await self?.tick() + try? await Task.sleep(for: .milliseconds(350)) + } + } + } + + private func tick() async { + if canAttemptValidation { + didAttemptValidation = true + validateAndFinish() + return + } + await resolveNow() + } + + private var canAttemptValidation: Bool { + !privateKey.isEmpty && !keyId.isEmpty && !issuerId.isEmpty + && !isValidating && !didAttemptValidation && validationError == nil + } + + // MARK: - Step resolution + + private func resolve(url: URL) async { + let urlString = url.absoluteString + let host = url.host ?? "" + // Apple's sign-in pages (its own auth domains + ASC's /login). DO NOT + // touch these — running JS or navigating mid-flow breaks Apple's + // multi-step auth and produces authResult=FAILED. Just reflect the + // sign-in step (until we've ever been in) and otherwise stand back. + let isAppleAuth = host.contains("idmsa.apple.com") + || host.contains("appleid.apple.com") + || host.contains("account.apple.com") + let isAscLogin = urlString.hasPrefix("https://appstoreconnect.apple.com/login") + if isAppleAuth || isAscLogin { + if !everLoggedIn { + setStep(.login) + } + // A failed silent/passkey attempt leaves a blank page. Recover the + // sign-in form exactly once by reloading the keys URL (which + // redirects to a clean /login). + if isAscLogin, !everLoggedIn, !recoverAttempted, + urlString.contains("authResult=FAILED") || urlString.contains("authResult=ERROR") { + recoverAttempted = true + webView?.load(URLRequest(url: Self.apiKeysURL)) + } + return + } + guard host == "appstoreconnect.apple.com" else { return } + if awaitingSwitchApply { + await applySwitch(urlString: urlString) + return + } + if urlString.hasPrefix(Self.apiKeysURLString) { + await resolveOnApiPage() + } else if !didAutoNavigate { + // A non-keys ASC page (e.g. /apps). Only steer to the keys page once + // a real session exists — navigating mid-login would break auth. + if let json = (try? await callJavaScript(FlowScripts.readSession)) as? String, + ASCSession.parse(jsonText: json) != nil { + didAutoNavigate = true + webView?.load(URLRequest(url: Self.apiKeysURL)) + } + } + } + + private func resolveOnApiPage() async { + didAutoNavigate = true + await fetchSessionIfNeeded() + // No session on the keys URL means we're NOT signed in (the page is + // about to redirect to /login). Do NOT advance — that false advance, + // followed by the redirect, is the oscillation. Stay on sign-in. + guard let session else { + FileHandle.standardError.write(Data("[resolveOnApiPage] keys URL but session=nil — staying on sign-in (everLoggedIn=\(everLoggedIn))\n".utf8)) + if !everLoggedIn { setStep(.login) } + return + } + everLoggedIn = true + recoverAttempted = false + if !didEmitSignedIn { + didEmitSignedIn = true + StatsProtocol.event("signed_in", ["team_count": session.teams.count]) + } + // Confirmation comes before any scraping: values captured under the + // wrong team would be worthless, and scrape failures during the + // dialog would fire bogus access warnings. + if needsTeamConfirmation { + captureWebSnapshotIfNeeded() + setStep(.selectTeam) + return + } + if !apiEnabledChecked { + setStep(.verifyAccess) + // Authoritative: did the team do "Request Access"? Fall back to the + // session feature flag only if the endpoint can't be read. + apiEnabledResult = await checkApiEnabled() ?? session.teamHasApiEnabled + apiEnabledChecked = true + } + let isEnabled = apiEnabledResult ?? true + let roleOk = session.userCanGenerateKeys + FileHandle.standardError.write(Data( + "[capability] team=\(session.currentTeam.name) roles=\(session.roles) apiEnabled=\(isEnabled) roleOk=\(roleOk)\n".utf8 + )) + if !didEmitCapability { + didEmitCapability = true + StatsProtocol.event("api_access_checked", ["enabled": isEnabled, "role_ok": roleOk]) + } + if !isEnabled || !roleOk { + accessDeniedReason = !isEnabled ? .notEnabled : .insufficientRole + if !didEmitAccessDenied { + didEmitAccessDenied = true + StatsProtocol.event("api_access_denied", [ + "reason": (!isEnabled) ? "not_enabled" : "insufficient_role", + ]) + } + setStep(.verifyAccess) + if !apiAccessWarningDismissed { flagApiAccessDenied() } + return + } + // Verified: this team has the API enabled and the role can create a key. + if issuerId.isEmpty { + await scrapeIssuerId() + } + // The keys URL also matches the brief moment before an unauthenticated + // visitor is redirected to /login. Until the session or the Issuer ID + // proves the page actually rendered, don't advance. + guard session != nil || !issuerId.isEmpty else { return } + guard !issuerId.isEmpty else { + setStep(.captureIssuerId) + return + } + if case .useExisting = mode { + setStep(.locateP8File) + return + } + await scrapeExistingKeys() + guard privateKey.isEmpty else { return } + // Key already created? (its row has a Download button) + if (try? await callJavaScript(FlowScripts.hasDownloadButton)) as? Bool == true { + if keyId.isEmpty { + await scrapeNewKeyId() + } + setStep(keyId.isEmpty ? .captureKeyId : .downloadKey) + return + } + // Generate dialog open? Guide name → role → Generate. + if (try? await callJavaScript(FlowScripts.isGenerateDialogOpen)) as? Bool == true { + await resolveGenerateDialog() + return + } + // Dialog closed; the “+” is on the page. + dialogNameFilled = false + selectedRoles = [] + if (try? await callJavaScript(FlowScripts.hasGenerateButton)) as? Bool == true { + setStep(.createKey) + } else { + setStep(.captureIssuerId) + } + } + + /// Inside the Generate API Key dialog: name first, then role, then Generate. + private func resolveGenerateDialog() async { + let nameFilled = (try? await callJavaScript(FlowScripts.readNameFilled)) as? Bool == true + dialogNameFilled = nameFilled + guard nameFilled else { + selectedRoles = [] + setStep(.nameKey) + return + } + let genEnabled = (try? await callJavaScript(FlowScripts.isGenerateEnabled)) as? Bool == true + guard genEnabled else { + // Name set, no role chosen yet. The selectRole highlight floats a + // native overlay over the Access field (never touching ASC's DOM). + selectedRoles = [] + setStep(.selectRole) + return + } + // A role is selected — read it so we can flag a non-Admin choice. + if let json = (try? await callJavaScript(FlowScripts.readSelectedRoles)) as? String, + let data = json.data(using: .utf8), + let roles = try? JSONDecoder().decode([String].self, from: data) { + selectedRoles = roles + } + // Wrong role → keep them on the role step (the panel shows a warning). + setStep(wrongRoleSelected ? .selectRole : .generateKey) + } + + private func setStep(_ step: FlowStep) { + guard step != currentStep else { return } + FileHandle.standardError.write(Data( + "[step] \(currentStep) -> \(step) | url=\(currentURL) | session=\(session?.currentTeam.name ?? "nil") everLoggedIn=\(everLoggedIn)\n".utf8 + )) + let previous = currentStep + currentStep = step + let elapsedOnPrev = Int(Date().timeIntervalSince(stepEnteredAt) * 1000) + stepEnteredAt = Date() + StatsProtocol.event("step_changed", [ + "from": String(describing: previous), + "to": String(describing: step), + "elapsed_ms_on_prev": elapsedOnPrev, + ]) + Task { + if let unhighlight = FlowScripts.unhighlightScript(for: previous) { + _ = try? await callJavaScript(unhighlight) + } + await highlightCurrentStep() + } + } + + private func highlightCurrentStep() async { + if let script = FlowScripts.highlightScript(for: currentStep) { + _ = try? await callJavaScript(script) + } + } + + // MARK: - Scraping + + private func scrapeIssuerId() async { + guard issuerId.isEmpty else { return } + if let value = (try? await callJavaScript(FlowScripts.readIssuerId)) as? String, + value.count >= 36 { + issuerId = value + statusMessage = "Issuer ID captured." + issuerScrapeFailures = 0 + scrapeTroubleWarning = false + } else { + // Permission is already gated by the session check upstream; a + // failure here means an accessible team's page didn't yield the + // Issuer ID (slow load or an Apple DOM change) — let the user paste. + issuerScrapeFailures += 1 + if issuerScrapeFailures >= 8, !apiAccessWarningDismissed { + scrapeTroubleWarning = true + } + } + } + + private func flagApiAccessDenied() { + guard !apiAccessDenied else { return } + apiAccessDenied = true + webSnapshot = nil + captureWebSnapshotIfNeeded() + Task { await fetchEligibleContacts() } + } + + private func fetchEligibleContacts() async { + guard eligibleContacts.isEmpty, + let json = (try? await callJavaScript(FlowScripts.readEligibleContacts)) as? String, + let data = json.data(using: .utf8), + let contacts = try? JSONDecoder().decode([TeamContact].self, from: data) else { + return + } + eligibleContacts = contacts + } + + /// Open the user's mail client with a prepared message to the right people, + /// based on why access is blocked. + func composeAccessEmail() { + let team = session?.currentTeam.name ?? "this team" + let accountHolders = eligibleContacts.filter(\.isAccountHolder) + let admins = eligibleContacts.filter { $0.isAdmin && !$0.isAccountHolder } + let recipients: [TeamContact] + let subject: String + let body: String + if accessDeniedReason == .notEnabled { + // Only the Account Holder can Request Access. + recipients = accountHolders.isEmpty ? admins : accountHolders + subject = "Enable the App Store Connect API for \(team)" + body = """ + Hi, + + I'm setting up Capgo Builder for \(team) and it needs an App Store Connect API key, but the API isn't enabled for the team yet. + + Could you turn it on? In App Store Connect: Users and Access → Integrations → App Store Connect API → Request Access (only the Account Holder can do this). Once it's enabled, an Admin can create a Team API key. + + Thanks! + """ + } else { + // API is on; any Admin or the Account Holder can create the key. + recipients = accountHolders + admins + subject = "App Store Connect API key for \(team)" + body = """ + Hi, + + I'm setting up Capgo Builder for \(team) and it needs an App Store Connect API Team Key — but only Admins or the Account Holder can create one. + + Could you either create a Team API key and send me the .p8 file plus its Key ID and Issuer ID, or grant me the Admin role (Users and Access) so I can create it myself? + + Thanks! + """ + } + openMailto(to: recipients.map(\.email), subject: subject, body: body) + } + + private func openMailto(to recipients: [String], subject: String, body: String) { + guard !recipients.isEmpty else { return } + var components = URLComponents() + components.scheme = "mailto" + components.path = recipients.joined(separator: ",") + components.queryItems = [ + URLQueryItem(name: "subject", value: subject), + URLQueryItem(name: "body", value: body), + ] + if let url = components.url { + NSWorkspace.shared.open(url) + } + } + + private func scrapeNewKeyId() async { + guard keyId.isEmpty else { return } + guard let value = (try? await callJavaScript(FlowScripts.readNewKeyId)) as? String else { return } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if (8...14).contains(trimmed.count), !trimmed.contains(" ") { + keyId = trimmed + statusMessage = "Key ID captured." + } + } + + private func scrapeExistingKeys() async { + guard let json = (try? await callJavaScript(FlowScripts.readExistingKeys)) as? String, + let data = json.data(using: .utf8), + let keys = try? JSONDecoder().decode([ExistingKey].self, from: data) else { + return + } + var seen = Set() + let unique = keys.filter { seen.insert($0.keyId).inserted } + if unique != existingKeys { + existingKeys = unique + } + } + + // MARK: - Existing-key path + + func selectExistingKey(_ key: ExistingKey) { + mode = .useExisting(key) + keyId = key.keyId + privateKey = "" + didAttemptValidation = false + validationError = nil + autoLocateMessage = nil + setStep(.locateP8File) + if let fileURL = P8FileLocator.locate(keyId: key.keyId), + let pem = try? String(contentsOf: fileURL, encoding: .utf8), + pem.contains("PRIVATE KEY") { + autoLocateMessage = "Found \(fileURL.path) — validating…" + privateKeyCaptured(pem) + } + } + + func switchToCreateNew() { + mode = .createNew + keyId = "" + privateKey = "" + didAttemptValidation = false + validationError = nil + autoLocateMessage = nil + } + + func chooseP8File() { + P8FileLocator.presentOpenPanel { [weak self] fileURL in + guard let self else { return } + Task { @MainActor in + guard let fileURL else { return } + guard let pem = try? String(contentsOf: fileURL, encoding: .utf8), + pem.contains("PRIVATE KEY") else { + self.validationError = "That file doesn’t look like a .p8 private key." + return + } + self.validationError = nil + self.privateKeyCaptured(pem, replace: true) + } + } + } + + // MARK: - Validation & finish + + func retryValidation() { + validationError = nil + didAttemptValidation = false + } + + private func validateAndFinish() { + isValidating = true + validationError = nil + statusMessage = nil + StatsProtocol.event("validation_started") + let validationStart = Date() + Task { + do { + try await validator.validate( + keyId: keyId.trimmingCharacters(in: .whitespacesAndNewlines), + issuerId: issuerId.trimmingCharacters(in: .whitespacesAndNewlines), + privateKeyPEM: privateKey + ) + statusMessage = "Key validated with Apple." + StatsProtocol.event("validation_succeeded", [ + "duration_ms": Int(Date().timeIntervalSince(validationStart) * 1000), + ]) + CredentialsEmitter.emit(KeyCredentials( + keyId: keyId.trimmingCharacters(in: .whitespacesAndNewlines), + issuerId: issuerId.trimmingCharacters(in: .whitespacesAndNewlines), + privateKey: privateKey + )) + } catch { + validationError = error.localizedDescription + StatsProtocol.event("validation_failed", [ + "duration_ms": Int(Date().timeIntervalSince(validationStart) * 1000), + ]) + } + isValidating = false + } + } +} diff --git a/cli/native/asc-key-helper/Sources/Models/KeyCredentials.swift b/cli/native/asc-key-helper/Sources/Models/KeyCredentials.swift new file mode 100644 index 0000000000..4ce2371537 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/KeyCredentials.swift @@ -0,0 +1,54 @@ +import Foundation + +/// The final product of the guided flow. Printed as JSON on stdout. +struct KeyCredentials: Codable { + let keyId: String + let issuerId: String + let privateKey: String +} + +/// Emits the captured credentials exactly once and terminates the process. +/// Exit code 0 = credentials on stdout; nonzero = cancelled/failed. +enum CredentialsEmitter { + private(set) static var didEmit = false + + static func emit(_ credentials: KeyCredentials) { + savePrivateKeyCopy(credentials) + didEmit = true + // The terminal `result` line of the stdout stats protocol IS the + // credential delivery — the CLI reads keyId/issuerId/privateKey from it. + StatsProtocol.result(credentials) + StatsProtocol.event("helper_finished", [ + "ok": true, + "outcome": "created", + "total_ms": StatsProtocol.elapsedMs(), + ]) + exit(0) + } + + static func exitCancelled() { + StatsProtocol.resultFailure(code: "USER_CANCELLED", message: "Window closed before a key was delivered.") + StatsProtocol.event("helper_finished", [ + "ok": false, + "outcome": "cancelled", + "total_ms": StatsProtocol.elapsedMs(), + ]) + exit(1) + } + + /// Keep a copy in the fastlane/ASC conventional location, since Apple + /// never allows the key to be downloaded again. + private static func savePrivateKeyCopy(_ credentials: KeyCredentials) { + let directory = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".appstoreconnect/private_keys", isDirectory: true) + let file = directory.appendingPathComponent("AuthKey_\(credentials.keyId).p8") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + guard !FileManager.default.fileExists(atPath: file.path) else { return } + try Data(credentials.privateKey.utf8).write(to: file) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + } catch { + FileHandle.standardError.write(Data("warning: could not save key copy: \(error)\n".utf8)) + } + } +} diff --git a/cli/native/asc-key-helper/Sources/Models/StatsProtocol.swift b/cli/native/asc-key-helper/Sources/Models/StatsProtocol.swift new file mode 100644 index 0000000000..5709ecff3d --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Models/StatsProtocol.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Newline-delimited JSON ("NDJSON") status protocol emitted on **stdout** for +/// a parent process (the Capgo CLI) to forward as analytics into PostHog. +/// +/// Every line is one self-describing envelope, tagged with `capgoAscKey` (the +/// protocol version) so the reader can ignore incidental stdout chatter: +/// +/// {"capgoAscKey":1,"kind":"event","ts":,"runId":"…","name":"step_changed","props":{…}} +/// {"capgoAscKey":1,"kind":"result","ts":,"runId":"…","ok":true,"keyId":"…","issuerId":"…","privateKey":"…"} +/// {"capgoAscKey":1,"kind":"result","ts":,"runId":"…","ok":false,"errorCode":"USER_CANCELLED","message":"…"} +/// +/// Contract: +/// - `event` lines carry only non-sensitive `props` — NEVER the private key. +/// - the terminal `result` line carries the credentials on success; it is the +/// only place the private key ever appears, and the CLI must not forward it +/// to analytics. +/// - human-readable diagnostics stay on **stderr** and are not part of this +/// protocol. +enum StatsProtocol { + static let version = 1 + /// Correlates every line emitted by a single helper run. + static let runId = UUID().uuidString + private static let startedAt = Date() + private static let stdout = FileHandle.standardOutput + /// Serialises writes so lines emitted from different tasks never interleave. + private static let queue = DispatchQueue(label: "app.capgo.asc-key.stats") + private static var didStart = false + + /// Milliseconds since the helper started — a simple run clock. + static func elapsedMs() -> Int { + Int(Date().timeIntervalSince(startedAt) * 1000) + } + + private static func writeLine(_ object: [String: Any]) { + var line = object + line["capgoAscKey"] = version + line["runId"] = runId + line["ts"] = elapsedMs() + guard JSONSerialization.isValidJSONObject(line), + let data = try? JSONSerialization.data(withJSONObject: line, options: [.sortedKeys]) else { + return + } + queue.sync { + stdout.write(data) + stdout.write(Data("\n".utf8)) + } + } + + /// Emit `helper_started` exactly once, at launch. + static func started() { + if didStart { return } + didStart = true + writeLine([ + "kind": "event", + "name": "helper_started", + "props": [ + "protocol_version": version, + "os_version": ProcessInfo.processInfo.operatingSystemVersionString, + ], + ]) + } + + /// Emit a non-sensitive analytics event. `props` must never contain secrets. + static func event(_ name: String, _ props: [String: Any] = [:]) { + writeLine(["kind": "event", "name": name, "props": props]) + } + + /// Terminal success line carrying the captured credentials. + static func result(_ credentials: KeyCredentials) { + writeLine([ + "kind": "result", + "ok": true, + "keyId": credentials.keyId, + "issuerId": credentials.issuerId, + "privateKey": credentials.privateKey, + ]) + } + + /// Terminal failure line (cancellation or internal error). + static func resultFailure(code: String, message: String) { + writeLine(["kind": "result", "ok": false, "errorCode": code, "message": message]) + } +} diff --git a/cli/native/asc-key-helper/Sources/P8ExtractApp.swift b/cli/native/asc-key-helper/Sources/P8ExtractApp.swift new file mode 100644 index 0000000000..7380e2d6f6 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/P8ExtractApp.swift @@ -0,0 +1,34 @@ +import AppKit +import SwiftUI + +@main +struct P8ExtractApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var model = GuidedFlowModel() + + var body: some Scene { + WindowGroup("App Store Connect API Key Setup") { + ContentView(model: model) + .frame(minWidth: 1100, minHeight: 700) + .onAppear { + StatsProtocol.started() + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + } + .defaultSize(width: 1280, height: 800) + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + true + } + + func applicationWillTerminate(_ notification: Notification) { + // Window closed without delivering credentials = user cancelled. + if !CredentialsEmitter.didEmit { + CredentialsEmitter.exitCancelled() + } + } +} diff --git a/cli/native/asc-key-helper/Sources/UI/ContentView.swift b/cli/native/asc-key-helper/Sources/UI/ContentView.swift new file mode 100644 index 0000000000..f2ab364597 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/UI/ContentView.swift @@ -0,0 +1,63 @@ +import SwiftUI +import WebKit + +struct ContentView: View { + @Bindable var model: GuidedFlowModel + + var body: some View { + HSplitView { + StepsPanel(model: model) + .frame(minWidth: 300, idealWidth: 340, maxWidth: 440) + VStack(spacing: 0) { + browserBar + Divider() + ZStack { + WebViewContainer(model: model) + if model.needsTeamConfirmation { + DialogOverlay(snapshot: model.webSnapshot) { + TeamConfirmDialog(model: model) + } + } else if model.apiAccessDenied { + DialogOverlay(snapshot: model.webSnapshot) { + ApiAccessDialog(model: model) + } + } + } + .animation(.easeInOut(duration: 0.2), value: model.needsTeamConfirmation) + .animation(.easeInOut(duration: 0.2), value: model.apiAccessDenied) + } + .frame(minWidth: 620, maxWidth: .infinity) + } + } + + private var browserBar: some View { + HStack(spacing: 8) { + Button { + model.webView?.goBack() + } label: { + Image(systemName: "chevron.left") + } + Button { + model.webView?.goForward() + } label: { + Image(systemName: "chevron.right") + } + Button { + model.webView?.reload() + } label: { + Image(systemName: "arrow.clockwise") + } + // Read-only URL display: lets the user verify they're on the real Apple site. + Text(model.currentURL) + .lineLimit(1) + .truncationMode(.middle) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) + .padding(.horizontal, 10) + .padding(.vertical, 6) + } +} diff --git a/cli/native/asc-key-helper/Sources/UI/FlowDialogs.swift b/cli/native/asc-key-helper/Sources/UI/FlowDialogs.swift new file mode 100644 index 0000000000..d36bed936f --- /dev/null +++ b/cli/native/asc-key-helper/Sources/UI/FlowDialogs.swift @@ -0,0 +1,170 @@ +import AppKit +import SwiftUI + +/// Pane-local modal: a real gaussian blur of the web view's own pixels +/// (snapshot-based — no AppKit material tinting), a light dim, and a centered +/// card. Swallows clicks so the page can't be used until resolved. +struct DialogOverlay: View { + let snapshot: NSImage? + @ViewBuilder let content: Content + + var body: some View { + ZStack { + if let snapshot { + GeometryReader { proxy in + Image(nsImage: snapshot) + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height) + .blur(radius: 22, opaque: true) + .clipped() + } + } + Color.black.opacity(0.22) + content + .padding(20) + .frame(maxWidth: 420) + .background( + Color(nsColor: .windowBackgroundColor), + in: RoundedRectangle(cornerRadius: 12) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.35), radius: 30, y: 10) + .padding(24) + } + .contentShape(Rectangle()) + .transition(.opacity) + } +} + +/// Shown over the blurred web view when the current team won't let the user +/// create an API key (no API access, or insufficient role). +struct ApiAccessDialog: View { + @Bindable var model: GuidedFlowModel + + private var teamName: String { model.session?.currentTeam.name ?? "This team" } + /// Only offer a switch when there is a team other than the current one. + private var hasOtherTeams: Bool { !(model.session?.otherTeams.isEmpty ?? true) } + /// Owner can enable; admins can create — label the email button to match. + private var emailButtonLabel: String { + model.accessDeniedReason == .notEnabled ? "Email the owner" : "Email an admin" + } + + /// State the actual reason, decided by the model's authoritative check. + private var reasonLines: [String] { + if model.accessDeniedReason == .notEnabled { + return [ + "“\(teamName)” hasn’t turned on the App Store Connect API yet. It’s a one-time switch the Account Holder (the org owner) has to flip.", + "Ask the owner to open Users and Access → Integrations → App Store Connect API and click “Request Access”. After that, an Admin can create the key.", + ] + } + // API is on; the blocker is the user's role. + let role = model.session?.role ?? "your role" + return [ + "Only Admins and the Account Holder can create a team API key. On “\(teamName)” you’re \(role), which can’t.", + "Ask an Admin or the Account Holder to create the key for you, or to upgrade your role under Users and Access.", + ] + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + Image(systemName: "lock.trianglebadge.exclamationmark.fill") + .font(.system(size: 22)) + .foregroundStyle(.orange) + Text("You can’t create a key for this team") + .font(.headline) + } + VStack(alignment: .leading, spacing: 8) { + ForEach(reasonLines, id: \.self) { line in + Text(line) + .font(.callout) + .foregroundStyle(.secondary) + } + } + HStack { + Button("Close") { + model.dismissApiAccessWarning() + } + Spacer() + if !model.eligibleContacts.isEmpty { + Button(emailButtonLabel) { + model.composeAccessEmail() + } + } + if hasOtherTeams { + Button("Choose another team") { + model.reopenTeamChoice() + } + .buttonStyle(.borderedProminent) + } + } + } + } +} + +/// Step 2 for multi-team accounts, shown over the blurred web view. +/// No team is preselected — the user must actively pick one, and Confirm +/// stays disabled until they do. +struct TeamConfirmDialog: View { + @Bindable var model: GuidedFlowModel + @State private var selectedTeamId: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text("Confirm your team") + .font(.headline) + Text("API keys belong to a team, and a key can’t be moved later. Pick the team your app ships under.") + .font(.callout) + .foregroundStyle(.secondary) + } + VStack(spacing: 5) { + ForEach(model.session?.teams ?? []) { team in + teamRow(team) + } + } + HStack { + Spacer() + Button("Confirm") { + if let team = model.session?.teams.first(where: { $0.id == selectedTeamId }) { + model.confirmTeamSelection(team) + } + } + .buttonStyle(.borderedProminent) + .disabled(selectedTeamId == nil) + } + } + } + + private func teamRow(_ team: ASCTeam) -> some View { + let isSelected = team.id == selectedTeamId + return Button { + selectedTeamId = team.id + } label: { + HStack(spacing: 8) { + TeamMonogram(name: team.name, size: 24) + Text(team.name) + .font(.system(size: 13)) + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + isSelected ? Color.accentColor.opacity(0.12) : Color.primary.opacity(0.04), + in: RoundedRectangle(cornerRadius: 8) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(isSelected ? Color.accentColor.opacity(0.5) : .clear, lineWidth: 1) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/cli/native/asc-key-helper/Sources/UI/StepsPanel.swift b/cli/native/asc-key-helper/Sources/UI/StepsPanel.swift new file mode 100644 index 0000000000..6f731a107c --- /dev/null +++ b/cli/native/asc-key-helper/Sources/UI/StepsPanel.swift @@ -0,0 +1,455 @@ +import AppKit +import SwiftUI + +/// The native left-hand panel: progress header, step checklist, captured +/// values, existing-key reuse, and a pinned status bar — on real sidebar material. +struct StepsPanel: View { + @Bindable var model: GuidedFlowModel + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + header + if model.isOffCourse { + offCourseBanner + } + teamCard + stepsCard + generateDialogCard + valuesCard + existingKeysCard + locateFileCard + } + .padding(14) + } + Divider() + statusBar + } + .background(SidebarMaterial().ignoresSafeArea()) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "key.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .background( + LinearGradient(colors: [.blue, .indigo], startPoint: .top, endPoint: .bottom), + in: RoundedRectangle(cornerRadius: 8) + ) + VStack(alignment: .leading, spacing: 2) { + Text("App Store Connect API Key") + .font(.headline) + Text("Step \(model.currentStepNumber) of \(model.steps.count)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + // MARK: - Off-course banner + + private var offCourseBanner: some View { + VStack(alignment: .leading, spacing: 8) { + Label("You’ve left the key setup page", systemImage: "location.slash.fill") + .font(.system(size: 13, weight: .semibold)) + Text("No problem — we’ll take you straight back to where you need to be.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Take me back") { + model.goToKeyPage() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.orange.opacity(0.14), in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.orange.opacity(0.45), lineWidth: 1) + ) + } + + // MARK: - Team + + @ViewBuilder + private var teamCard: some View { + // Don't show a team as "yours" while the choice is still pending. + if let session = model.session, !model.needsTeamConfirmation { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + TeamMonogram(name: session.currentTeam.name, size: 26) + VStack(alignment: .leading, spacing: 1) { + Text(session.currentTeam.name) + .font(.system(size: 13, weight: .semibold)) + Text(signedInCaption(session)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !model.needsTeamConfirmation, !session.otherTeams.isEmpty { + Menu("Switch") { + ForEach(session.otherTeams) { team in + Button(team.name) { + model.switchTeam(to: team) + } + } + } + .font(.caption) + .fixedSize() + } + } + } + .padding(12) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + } + + private func signedInCaption(_ session: ASCSession) -> String { + var caption = session.email.isEmpty ? "Signed in" : "Signed in as \(session.email)" + if let role = session.role { + caption += " · \(role)" + } + return caption + } + + // MARK: - Steps + + private var stepsCard: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(model.steps.enumerated()), id: \.element) { index, step in + stepRow(index: index, step: step) + } + } + .padding(6) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + + private func stepRow(index: Int, step: FlowStep) -> some View { + let state = model.state(of: step) + return HStack(alignment: .top, spacing: 10) { + stepBadge(index: index, state: state) + VStack(alignment: .leading, spacing: 3) { + Text(step.instruction) + .font(.system(size: 13, weight: state == .current ? .semibold : .regular)) + .foregroundStyle(state == .upcoming ? .secondary : .primary) + if state == .current, let detail = step.detail { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .padding(.vertical, 8) + .padding(.horizontal, 8) + .background( + state == .current ? Color.accentColor.opacity(0.13) : .clear, + in: RoundedRectangle(cornerRadius: 7) + ) + .animation(.easeInOut(duration: 0.2), value: state == .current) + } + + @ViewBuilder + private func stepBadge(index: Int, state: StepState) -> some View { + ZStack { + switch state { + case .done: + Circle().fill(.green) + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + case .current: + Circle().fill(Color.accentColor) + Text("\(index + 1)") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + case .upcoming: + Circle().strokeBorder(Color.secondary.opacity(0.45), lineWidth: 1.2) + Text("\(index + 1)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + } + .frame(width: 20, height: 20) + .padding(.top, 1) + } + + // MARK: - Generate dialog helpers + + @ViewBuilder + private var generateDialogCard: some View { + if model.canAutofillName { + helperCard { + Text("Fill the name for me") + .font(.system(size: 13, weight: .semibold)) + Button { + model.autofillKeyName() + } label: { + Label("Autofill “Capgo Builder”", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + Text("Or type your own name — this button goes away once you do.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else if model.currentStep == .selectRole { + VStack(alignment: .leading, spacing: 10) { + if model.wrongRoleSelected { + wrongRoleWarning + } + helperCard { + Text("Pick the role for me") + .font(.system(size: 13, weight: .semibold)) + Button { + model.autofillKeyRole() + } label: { + Label("Set role to Admin", systemImage: "wand.and.stars") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + } + } + } + + /// Loud, can't-miss warning when a non-Admin role is selected. + private var wrongRoleWarning: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Wrong role — this key won’t work", systemImage: "exclamationmark.octagon.fill") + .font(.system(size: 13, weight: .bold)) + Text("You selected \(model.selectedRoles.joined(separator: ", ")). Capgo Builder needs the Admin role. Remove those chips (the × next to each) and pick Admin — or just use the button below.") + .font(.caption) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.red, in: RoundedRectangle(cornerRadius: 10)) + } + + private func helperCard(@ViewBuilder _ content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.accentColor.opacity(0.18), in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1) + ) + } + + // MARK: - Captured values + + @ViewBuilder + private var valuesCard: some View { + // Issuer ID is shown once captured, or as a manual-entry fallback only + // if auto-capture has actually failed. Key ID is shown once captured. + let issuerReached = model.state(of: .captureIssuerId) != .upcoming + let showIssuerManual = model.issuerId.isEmpty && issuerReached && model.scrapeTroubleWarning + let showIssuer = !model.issuerId.isEmpty || showIssuerManual + let showKey = !model.keyId.isEmpty + if showIssuer || showKey { + VStack(alignment: .leading, spacing: 12) { + if showIssuer { + if model.issuerId.isEmpty { + manualValueRow( + "Issuer ID", + text: $model.issuerId, + warning: "We couldn’t read the Issuer ID automatically. Copy it from the page (next to “Issuer ID”) and paste it here." + ) + } else { + capturedValueRow("Issuer ID", value: model.issuerId) + } + } + if showKey { + capturedValueRow("Key ID", value: model.keyId) + } + } + .padding(12) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + } + + /// A value we captured automatically — read-only and clearly labelled so it + /// can't be corrupted by an accidental edit. + private func capturedValueRow(_ label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 5) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + Label("Captured", systemImage: "checkmark.seal.fill") + .font(.caption2) + .foregroundStyle(.green) + } + Text(value) + .font(.callout.monospaced()) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 5) + .padding(.horizontal, 8) + .background(Color.primary.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) + Text("Read automatically from App Store Connect — no need to edit it.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + /// Editable fallback, shown only when auto-capture failed — with a warning. + private func manualValueRow(_ label: String, text: Binding, warning: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Label(label, systemImage: "exclamationmark.triangle.fill") + .font(.caption.weight(.medium)) + .foregroundStyle(.orange) + TextField("Paste the \(label) here", text: text) + .textFieldStyle(.roundedBorder) + .font(.callout.monospaced()) + Text(warning) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + // MARK: - Existing keys + + @ViewBuilder + private var existingKeysCard: some View { + if model.showExistingKeysSection { + VStack(alignment: .leading, spacing: 8) { + Label("Already have a team key?", systemImage: "key.viewfinder") + .font(.system(size: 13, weight: .semibold)) + Text("Reuse one if you still have its .p8 file — Apple doesn’t allow re-downloading.") + .font(.caption) + .foregroundStyle(.secondary) + VStack(spacing: 4) { + ForEach(model.existingKeys) { key in + Button { + model.selectExistingKey(key) + } label: { + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(key.name) + .font(.system(size: 12)) + Text(key.keyId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Text("Use") + .font(.caption.weight(.medium)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + } + .padding(12) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + } + + // MARK: - Existing-key file + + @ViewBuilder + private var locateFileCard: some View { + if case .useExisting(let key) = model.mode { + VStack(alignment: .leading, spacing: 8) { + Label("Reusing key \(key.keyId)", systemImage: "doc.badge.ellipsis") + .font(.system(size: 13, weight: .semibold)) + if model.privateKey.isEmpty { + Text(model.autoLocateMessage ?? "Select the AuthKey_\(key.keyId).p8 file you saved when this key was created. We already checked the usual folders.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Choose .p8 file…") { + model.chooseP8File() + } + .controlSize(.small) + } else if let found = model.autoLocateMessage { + Text(found) + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Back to creating a new key") { + model.switchToCreateNew() + } + .buttonStyle(.link) + .controlSize(.small) + } + .padding(12) + .background(Color.primary.opacity(0.05), in: RoundedRectangle(cornerRadius: 10)) + } + } + + // MARK: - Status bar + + private var statusBar: some View { + HStack(spacing: 7) { + if model.isValidating { + ProgressView() + .controlSize(.small) + Text("Validating key with Apple…") + } else if let error = model.validationError { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + Text(error) + .lineLimit(3) + Spacer(minLength: 4) + Button("Retry") { + model.retryValidation() + } + .controlSize(.small) + } else if model.showsApiAccessNote { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("This team has no App Store Connect API access — ask the Account Holder to enable it under Users and Access → Integrations.") + .lineLimit(3) + } else if model.scrapeTroubleWarning { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Can’t find the Issuer ID — your account may lack team-key access (ask your Account Holder), or paste the values manually.") + .lineLimit(3) + } else if let status = model.statusMessage { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(status) + } else { + Text("Follow the highlighted step on the page.") + .foregroundStyle(.tertiary) + } + Spacer(minLength: 0) + } + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 9) + } +} + +/// Sidebar background. Uses the window-background material with within-window +/// blending so it stays a consistent neutral tone instead of bleeding the +/// desktop wallpaper's colors through. +private struct SidebarMaterial: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .windowBackground + view.blendingMode = .withinWindow + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} diff --git a/cli/native/asc-key-helper/Sources/UI/TeamMonogram.swift b/cli/native/asc-key-helper/Sources/UI/TeamMonogram.swift new file mode 100644 index 0000000000..059b053abc --- /dev/null +++ b/cli/native/asc-key-helper/Sources/UI/TeamMonogram.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// ASC has no team images — Apple's own UI draws a monogram, so we do too: +/// initials on a circle whose hue is derived deterministically from the name. +struct TeamMonogram: View { + let name: String + let size: CGFloat + + var body: some View { + ZStack { + Circle().fill(color) + Text(initials) + .font(.system(size: size * 0.42, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + } + + private var initials: String { + name.split(separator: " ") + .prefix(2) + .compactMap(\.first) + .map(String.init) + .joined() + .uppercased() + } + + private var color: Color { + let seed = name.unicodeScalars.reduce(UInt32(0)) { $0 &* 31 &+ $1.value } + return Color(hue: Double(seed % 360) / 360, saturation: 0.55, brightness: 0.65) + } +} diff --git a/cli/native/asc-key-helper/Sources/Util/P8FileLocator.swift b/cli/native/asc-key-helper/Sources/Util/P8FileLocator.swift new file mode 100644 index 0000000000..463cc7719f --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Util/P8FileLocator.swift @@ -0,0 +1,37 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +/// Finds AuthKey .p8 files in the places people (and fastlane) conventionally keep them. +enum P8FileLocator { + static func conventionalDirectories() -> [URL] { + let home = FileManager.default.homeDirectoryForCurrentUser + let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + return [ + cwd.appendingPathComponent("private_keys"), + home.appendingPathComponent("private_keys"), + home.appendingPathComponent(".private_keys"), + home.appendingPathComponent(".appstoreconnect/private_keys"), + home.appendingPathComponent("Downloads"), + ] + } + + static func locate(keyId: String) -> URL? { + let fileName = "AuthKey_\(keyId).p8" + return conventionalDirectories() + .map { $0.appendingPathComponent(fileName) } + .first { FileManager.default.fileExists(atPath: $0.path) } + } + + static func presentOpenPanel(completion: @escaping (URL?) -> Void) { + let panel = NSOpenPanel() + panel.title = "Select your AuthKey .p8 file" + panel.allowedContentTypes = [UTType(filenameExtension: "p8") ?? .item] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.directoryURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads") + panel.begin { response in + completion(response == .OK ? panel.url : nil) + } + } +} diff --git a/cli/native/asc-key-helper/Sources/Validation/ASCKeyValidator.swift b/cli/native/asc-key-helper/Sources/Validation/ASCKeyValidator.swift new file mode 100644 index 0000000000..9920818cce --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Validation/ASCKeyValidator.swift @@ -0,0 +1,64 @@ +import CryptoKit +import Foundation + +struct ValidationFailure: LocalizedError { + let message: String + var errorDescription: String? { message } +} + +/// Validates a team API key by signing an ES256 JWT and calling the official +/// App Store Connect API. +struct ASCKeyValidator { + func validate(keyId: String, issuerId: String, privateKeyPEM: String) async throws { + let token = try makeJWT(keyId: keyId, issuerId: issuerId, privateKeyPEM: privateKeyPEM) + var request = URLRequest(url: URL(string: "https://api.appstoreconnect.apple.com/v1/apps?limit=1")!) + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let (_, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ValidationFailure(message: "No HTTP response from App Store Connect.") + } + switch http.statusCode { + case 200...299: + return + case 401: + throw ValidationFailure(message: "Apple rejected the key (401). The .p8 file may not match this Key ID / Issuer ID, or the key was revoked.") + case 403: + // The key authenticates; the role just limits some resources. + return + default: + throw ValidationFailure(message: "Unexpected response from Apple (HTTP \(http.statusCode)).") + } + } + + private func makeJWT(keyId: String, issuerId: String, privateKeyPEM: String) throws -> String { + let header: [String: String] = ["alg": "ES256", "kid": keyId, "typ": "JWT"] + let now = Int(Date().timeIntervalSince1970) + let payload: [String: Any] = [ + "iss": issuerId, + "iat": now, + "exp": now + 600, + "aud": "appstoreconnect-v1", + ] + let headerPart = base64URL(try JSONSerialization.data(withJSONObject: header, options: [.sortedKeys])) + let payloadPart = base64URL(try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])) + let signingInput = "\(headerPart).\(payloadPart)" + + let key: P256.Signing.PrivateKey + do { + key = try P256.Signing.PrivateKey( + pemRepresentation: privateKeyPEM.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } catch { + throw ValidationFailure(message: "Could not parse the .p8 private key: \(error.localizedDescription)") + } + let signature = try key.signature(for: Data(signingInput.utf8)) + return "\(signingInput).\(base64URL(signature.rawRepresentation))" + } + + private func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/cli/native/asc-key-helper/Sources/Web/WebViewContainer.swift b/cli/native/asc-key-helper/Sources/Web/WebViewContainer.swift new file mode 100644 index 0000000000..b27f84a8a8 --- /dev/null +++ b/cli/native/asc-key-helper/Sources/Web/WebViewContainer.swift @@ -0,0 +1,164 @@ +import SwiftUI +import WebKit + +/// Hosts the WKWebView showing the real App Store Connect site and wires +/// navigation events, JavaScript execution and private-key capture into the model. +/// +/// The P8 capture trick (Apple serves the key download as a `data:` URL, which +/// we intercept and decode in memory) is adapted from AppStoreConnectKit +/// (https://github.com/MortenGregersen/AppStoreConnectKit), +/// MIT License, © Morten Bjerg Gregersen. See THIRD-PARTY-LICENSES.md. +struct WebViewContainer: NSViewRepresentable { + let model: GuidedFlowModel + + func makeCoordinator() -> Coordinator { + Coordinator(model: model) + } + + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + // Start with a clean session each launch. Persisting the Apple ID login + // proved unreliable: only part of the session survived, so ASC rejected + // it (authResult=FAILED) and the flow oscillated between advanced and + // signed-out. A non-persistent store guarantees a clean sign-in. Proper + // cookie persistence can be revisited for the real CLI later. + configuration.websiteDataStore = .nonPersistent() + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.navigationDelegate = context.coordinator + if #available(macOS 13.3, *) { + #if DEBUG + webView.isInspectable = true + #endif + } + context.coordinator.attach(webView: webView) + // Start at the keys page: logged-out users get Apple's login wall with + // this page as the post-login redirect target. + webView.load(URLRequest(url: GuidedFlowModel.apiKeysURL)) + return webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} + + @MainActor + final class Coordinator: NSObject { + private let model: GuidedFlowModel + private var urlObservation: NSKeyValueObservation? + private var downloadDestination: URL? + + init(model: GuidedFlowModel) { + self.model = model + } + + func attach(webView: WKWebView) { + model.webView = webView + model.callJavaScript = { [weak webView] script in + guard let webView else { return nil } + return try await webView.callAsyncJavaScript( + script, + arguments: [:], + in: nil, + contentWorld: .defaultClient + ) + } + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in + guard let url = change.newValue.flatMap({ $0 }) else { return } + Task { @MainActor in + self?.model.urlChanged(url) + } + } + } + + static func decodePEM(fromDataURL url: URL) -> String? { + let absolute = url.absoluteString + guard let commaIndex = absolute.firstIndex(of: ",") else { return nil } + let header = absolute[.. Void + ) { + if let url = navigationAction.request.url, url.scheme == "data" { + if let pem = Self.decodePEM(fromDataURL: url) { + model.privateKeyCaptured(pem) + } + decisionHandler(.cancel) + return + } + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + return + } + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.canShowMIMEType { + decisionHandler(.download) + return + } + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + download.delegate = self + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + download.delegate = self + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + model.pageDidFinishLoading() + } +} + +// MARK: - WKDownloadDelegate (fallback capture if the key arrives as a real download) + +extension WebViewContainer.Coordinator: WKDownloadDelegate { + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String, + completionHandler: @escaping (URL?) -> Void + ) { + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString)-\(suggestedFilename)") + downloadDestination = destination + completionHandler(destination) + } + + func downloadDidFinish(_ download: WKDownload) { + guard let destination = downloadDestination, + destination.pathExtension == "p8", + let pem = try? String(contentsOf: destination, encoding: .utf8), + pem.contains("PRIVATE KEY") else { + return + } + model.privateKeyCaptured(pem) + try? FileManager.default.removeItem(at: destination) + } + + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + downloadDestination = nil + } +} diff --git a/cli/native/asc-key-helper/THIRD-PARTY-LICENSES.md b/cli/native/asc-key-helper/THIRD-PARTY-LICENSES.md new file mode 100644 index 0000000000..4166f2eafe --- /dev/null +++ b/cli/native/asc-key-helper/THIRD-PARTY-LICENSES.md @@ -0,0 +1,29 @@ +# Third-party licenses + +## AppStoreConnectKit + +Portions of this software (App Store Connect step detection scripts, element +highlighting, and the `data:` URL private-key capture technique) are adapted +from AppStoreConnectKit: https://github.com/MortenGregersen/AppStoreConnectKit + +MIT License + +Copyright (c) 2025 Morten Bjerg Gregersen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/cli/scripts/build-asc-key-helper.sh b/cli/scripts/build-asc-key-helper.sh index bb0b3958ac..035a517e02 100755 --- a/cli/scripts/build-asc-key-helper.sh +++ b/cli/scripts/build-asc-key-helper.sh @@ -11,10 +11,12 @@ # 2. ~/.capgo/asc-key-helper/capgo-asc-key-helper (cached download) # # Usage: -# scripts/build-asc-key-helper.sh [out-dir] +# scripts/build-asc-key-helper.sh [path-to-helper-swift-package] [out-dir] +# +# Defaults to the in-repo package at cli/native/asc-key-helper. # # Example: -# scripts/build-asc-key-helper.sh ~/Developer/test-p8-extract dist-helper +# scripts/build-asc-key-helper.sh # builds the vendored package # export CAPGO_ASC_KEY_HELPER_PATH="$PWD/dist-helper/capgo-asc-key-helper" # set -euo pipefail @@ -24,14 +26,17 @@ if [[ "$(uname)" != "Darwin" ]]; then exit 1 fi -SRC_DIR="${1:-}" +# Default to the vendored package next to this script (cli/native/asc-key-helper). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_SRC="$SCRIPT_DIR/../native/asc-key-helper" +SRC_DIR="${1:-$DEFAULT_SRC}" OUT_DIR="${2:-dist-helper}" PRODUCT_NAME="P8Extract" # SwiftPM executable product name OUT_BINARY="capgo-asc-key-helper" # canonical name the CLI looks for -if [[ -z "$SRC_DIR" || ! -f "$SRC_DIR/Package.swift" ]]; then - echo "error: pass the helper Swift package dir (the folder with Package.swift)." >&2 - echo "usage: $0 [out-dir]" >&2 +if [[ ! -f "$SRC_DIR/Package.swift" ]]; then + echo "error: no Package.swift at '$SRC_DIR'." >&2 + echo "usage: $0 [path-to-helper-swift-package] [out-dir]" >&2 exit 1 fi diff --git a/cli/src/build/onboarding/asc-key/helper.ts b/cli/src/build/onboarding/asc-key/helper.ts index 0e9ff60271..41db5f965d 100644 --- a/cli/src/build/onboarding/asc-key/helper.ts +++ b/cli/src/build/onboarding/asc-key/helper.ts @@ -3,8 +3,9 @@ import type { AscCredentials, AscEventLine, AscProtocolLine, AscResultLine } fro import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { homedir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { env, platform } from 'node:process' +import { fileURLToPath } from 'node:url' import { trackEvent } from '../../../analytics/track' import { ascEventToTrack, AscProtocolParser } from './protocol' @@ -23,10 +24,38 @@ export function isMacOS(): boolean { return platform === 'darwin' } +/** SwiftPM product name of the vendored helper package (cli/native/asc-key-helper). */ +const HELPER_PRODUCT_NAME = 'P8Extract' + +/** + * Dev/CI fallback: a `swift build` output of the vendored package, resolved + * relative to this module. Empty in a bundled install (the package isn't + * shipped to npm), so this only ever resolves when running from the repo. + */ +function localBuildCandidates(): string[] { + let here: string + try { + here = dirname(fileURLToPath(import.meta.url)) + } + catch { + return [] + } + // From src/build/onboarding/asc-key → repo `cli/native/asc-key-helper`. + const pkg = join(here, '..', '..', '..', '..', 'native', 'asc-key-helper', '.build') + return [ + join(pkg, 'apple', 'Products', 'Release', HELPER_PRODUCT_NAME), // universal + join(pkg, 'release', HELPER_PRODUCT_NAME), + join(pkg, 'arm64-apple-macosx', 'release', HELPER_PRODUCT_NAME), + join(pkg, 'debug', HELPER_PRODUCT_NAME), + join(pkg, 'arm64-apple-macosx', 'debug', HELPER_PRODUCT_NAME), + ] +} + /** * Locate the precompiled Swift helper binary, in priority order: * 1. `CAPGO_ASC_KEY_HELPER_PATH` — explicit override (dev / CI / tests). * 2. `~/.capgo/asc-key-helper/` — the cached download location. + * 3. A local `swift build` of the vendored package (dev, running from src). * Returns `null` when none exists, so the caller can show install guidance. */ export function resolveHelperBinary(): string | null { @@ -36,6 +65,10 @@ export function resolveHelperBinary(): string | null { const cached = join(homedir(), '.capgo', 'asc-key-helper', HELPER_BINARY_NAME) if (existsSync(cached)) return cached + for (const candidate of localBuildCandidates()) { + if (existsSync(candidate)) + return candidate + } return null } diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts index 7a715e5310..2deecda99b 100644 --- a/cli/src/build/onboarding/types.ts +++ b/cli/src/build/onboarding/types.ts @@ -54,6 +54,10 @@ export type OnboardingStep | 'import-export-warning' | 'import-exporting' // ── Existing create-new sub-flow (and ASC API key step reused by import for app_store) ── + // Do-you-have-a-.p8 fork: have one → existing import; none + macOS → create. + | 'p8-source-select' + | 'p8-create-method-select' + | 'asc-key-generating' | 'api-key-instructions' | 'p8-method-select' | 'input-p8-path' diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx index 9d693d30a0..eb24ddf369 100644 --- a/cli/src/build/onboarding/ui/app.tsx +++ b/cli/src/build/onboarding/ui/app.tsx @@ -1,89 +1,90 @@ +import type { DOMElement } from 'ink' import type { FC } from 'react' +import type { BuildCredentials } from '../../../schemas/build.js' import type { BuildLogger } from '../../request.js' +import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../analytics.js' +import type { AscAppLike, GatePath } from '../app-verification.js' +import type { AscApp, AscProfileSummary } from '../apple-api.js' +import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../ci-secrets.js' +import type { DiffLine } from '../diff-utils.js' import type { DiscoveredProfile, IdentityProfileMatch, SigningIdentity } from '../macos-signing.js' +import type { BuilderOnboardingAction } from '../telemetry.js' import type { ApiKeyData, CertificateData, EnrichedIdentityAvailability, OnboardingErrorCategory, OnboardingProgress, OnboardingResult, OnboardingStep, ProfileData } from '../types.js' -import { handleCustomMsg } from '../../qr.js' -import { spawn } from 'node:child_process' +import type { BuildScriptChoice, PackageManager } from '../workflow-generator.js' +import type { AiResultKind } from './components.js' +import type { NoMatchReason } from './steps/ios-import.js' import { Buffer } from 'node:buffer' +import { spawn } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { copyFile, readFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join, resolve } from 'node:path' import process from 'node:process' import { Alert, ProgressBar, Select } from '@inkjs/ui' -import type { DOMElement } from 'ink' import { Box, measureElement, Newline, Text, useApp, useInput, useStdout } from 'ink' import open from 'open' // src/build/onboarding/ui/app.tsx import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -// Braille spinner frames for the per-row "Profile" cell during prefetch. -// Module-scoped so the array reference is stable and never triggers -// re-renders by accident. -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const -import { detectIosBundleIds } from '../bundle-id-detector.js' -import { writeReleaseBundleId } from '../../pbxproj-parser.js' +import { releaseCapturedLogs, runCapgoAiAnalysis } from '../../../ai/analyze.js' +import { renderMarkdown } from '../../../ai/render-markdown.js' +import { createStreamingMarkdownRenderer } from '../../../ai/stream-markdown.js' +import { aiAnalysisResultFromPostAnalyze, trackAiAnalysisChoice, trackAiAnalysisResult } from '../../../ai/telemetry.js' +import { trackEvent } from '../../../analytics/track.js' import { writeOnboardingSupportBundle, writeSupportBundleFiles } from '../../../onboarding-support.js' -import { contactSupport } from '../../../support/contact-support.js' -import { uploadSupportLogs } from '../../../support/support-upload.js' +import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' import { copyToClipboard, revealInFinder } from '../../../support/clipboard.js' +import { contactSupport } from '../../../support/contact-support.js' import { appendInternalLog, getInternalLogPath } from '../../../support/internal-log.js' import { redactSecrets } from '../../../support/redact.js' -import { formatRunnerCommand, splitRunnerCommand } from '../../../runner-command.js' +import { uploadSupportLogs } from '../../../support/support-upload.js' import { createSupabaseClient, findBuildCommandForProjectType, findProjectType, findSavedKeySilent, getOrganizationId, getPackageScripts, getPMAndCommand } from '../../../utils.js' import { loadSavedCredentials, updateSavedCredentials } from '../../credentials.js' -import { releaseCapturedLogs, runCapgoAiAnalysis } from '../../../ai/analyze.js' -import { createStreamingMarkdownRenderer } from '../../../ai/stream-markdown.js' -import { renderMarkdown } from '../../../ai/render-markdown.js' -import { aiAnalysisResultFromPostAnalyze, trackAiAnalysisChoice, trackAiAnalysisResult } from '../../../ai/telemetry.js' +import { parseMobileprovisionDetailed } from '../../mobileprovision-parser.js' +import { writeReleaseBundleId } from '../../pbxproj-parser.js' +import { handleCustomMsg } from '../../qr.js' import { requestBuildInternal } from '../../request.js' import { isAiAnalysisTooTall, resolveAiResultRoute } from '../ai-fit.js' - -// Upper bound on "I fixed it, retry build" attempts after an AI diagnosis. -// Three total attempts (initial + two retries) caps the AI cost when a model -// suggestion doesn't actually fix the failure mode while still giving the user -// a couple of in-wizard chances to iterate. -const MAX_AI_RETRIES = 2 -import type { AscApp, AscProfileSummary } from '../apple-api.js' -import { CertificateLimitError, classifyCertAvailability, computeCertSha1, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertIdBySha1, generateJwt, listApps, listBundleIds, listDistributionCerts, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' -import type { AscAppLike, GatePath } from '../app-verification.js' +import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../analytics.js' import { classifyAppVerification, evaluateGate } from '../app-verification.js' -import { trackEvent } from '../../../analytics/track.js' +import { CertificateLimitError, classifyCertAvailability, computeCertSha1, createCertificate, createProfile, deleteProfile, DuplicateProfileError, ensureBundleId, findCertIdBySha1, generateJwt, listApps, listBundleIds, listDistributionCerts, listProfilesForCert, revokeCertificate, verifyApiKey } from '../apple-api.js' +import { resolveHelperBinary, runAscKeyHelper } from '../asc-key/helper.js' +import { sanitizeBuildLogLines } from '../build-log.js' +import { detectIosBundleIds } from '../bundle-id-detector.js' +import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../ci-secrets.js' import { createP12, DEFAULT_P12_PASSWORD, generateCsr } from '../csr.js' +import { diffLines } from '../diff-utils.js' +import { defaultExportPath, exportCredentialsToEnv } from '../env-export.js' import { mapIosOnboardingError } from '../error-categories.js' import { canUseFilePicker, openFilePicker, openMobileprovisionPicker } from '../file-picker.js' -import { parseMobileprovisionDetailed } from '../../mobileprovision-parser.js' import { bundleIdMatches, exportP12FromKeychain, filterProfilesForApp, isMacOS, listSigningIdentities, matchIdentitiesToProfiles, scanProvisioningProfiles } from '../macos-signing.js' +import { IOS_MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' import { deleteProgress, extractKeyIdFromP8Path, getImportEntryStep, getResumeStep, loadProgress, saveProgress } from '../progress.js' import { getBuildOnboardingRecoveryAdvice } from '../recovery.js' -import { createCiSecretEntries, detectCiSecretTargets, getCiSecretRepoLabelAsync, getCiSecretTargetLabel, listExistingCiSecretKeysAsync, uploadCiSecretsAsync } from '../ci-secrets.js' -import type { CiSecretEntry, CiSecretSetupAdvice, CiSecretTarget } from '../ci-secrets.js' -import { defaultExportPath, exportCredentialsToEnv } from '../env-export.js' -import type { BuilderOnboardingAction } from '../telemetry.js' import { trackBuilderOnboardingAction, trackBuilderOnboardingStep } from '../telemetry.js' -import { writeWorkflowFile, WORKFLOW_PATH } from '../workflow-writer.js' -import type { BuildScriptChoice, PackageManager } from '../workflow-generator.js' -import type { BuildCredentials } from '../../../schemas/build.js' import { getPhaseLabel, STEP_PROGRESS, } from '../types.js' +import { generateWorkflow, WORKFLOW_PATH as WORKFLOW_GEN_PATH } from '../workflow-generator.js' +import { buildScriptPickerOptions, normalizePackageManager } from '../workflow-ui-helpers.js' +import { WORKFLOW_PATH, writeWorkflowFile } from '../workflow-writer.js' import { CompletedStepsLog } from './completed-steps-log.js' -import { IOS_MIN_ROWS, terminalFitsOnboarding } from '../min-terminal-size.js' -import { sanitizeBuildLogLines } from '../build-log.js' -import { TerminalTooSmallPrompt } from './min-size-gate.js' import { BOX_HEADER_ROWS, COMPACT_HEADER_ROWS, DiffSummary, Divider, FilteredTextInput, FullscreenAiViewer, FullscreenBuildOutput, FullscreenDiffViewer, Header, isBuildCompleteDismissKey, SecretsTable, SpinnerLine, SuccessLine, Table, WIZARD_PADDING_ROWS } from './components.js' -import type { AiResultKind } from './components.js' import { logBudgetRows } from './frame-fit.js' -import { diffLines } from '../diff-utils.js' -import type { DiffLine } from '../diff-utils.js' -import { generateWorkflow, WORKFLOW_PATH as WORKFLOW_GEN_PATH } from '../workflow-generator.js' -import { getWorkflowDiffTelemetry, trackBuildOnboardingWorkflowEvent } from '../analytics.js' -import type { BuildOnboardingWorkflowDecision, BuildOnboardingWorkflowEvent, WorkflowDiffTelemetry } from '../analytics.js' -import { buildScriptPickerOptions, normalizePackageManager } from '../workflow-ui-helpers.js' +import { TerminalTooSmallPrompt } from './min-size-gate.js' +import { + AskBuildStep, + AskCiSecretsStep, + CiSecretsFailedStep, + CiSecretsSetupStep, + CiSecretsTargetSelectStep, + ConfirmCiSecretOverwriteStep, + DetectingCiSecretsStep, +} from './steps/ios-ci.js' import { ApiKeyInstructionsStep, + AscKeyGeneratingStep, BackingUpStep, CertLimitPromptStep, CreatingCertificateStep, @@ -94,21 +95,14 @@ import { InputIssuerIdStep, InputKeyIdStep, InputP8PathStep, + P8CreateMethodSelectStep, P8MethodSelectStep, + P8SourceSelectStep, RevokingCertificateStep, SavingCredentialsStep, SetupMethodSelectStep, VerifyingKeyStep, } from './steps/ios-credentials.js' -import { - AskBuildStep, - AskCiSecretsStep, - CiSecretsFailedStep, - CiSecretsSetupStep, - CiSecretsTargetSelectStep, - ConfirmCiSecretOverwriteStep, - DetectingCiSecretsStep, -} from './steps/ios-ci.js' import { ImportCreateProfileOnlyStep, ImportDistributionModeStep, @@ -119,12 +113,11 @@ import { ImportPickProfileStep, ImportScanningStep, } from './steps/ios-import.js' -import type { NoMatchReason } from './steps/ios-import.js' import { AddingPlatformStep, AiAnalysisPromptStep, - AiAnalysisRunningStep, AiAnalysisResultStep, + AiAnalysisRunningStep, BuildCompleteStep, ErrorStep, estimateErrorBodyRows, @@ -134,6 +127,17 @@ import { WelcomeStep, } from './steps/ios-shared.js' +// Braille spinner frames for the per-row "Profile" cell during prefetch. +// Module-scoped so the array reference is stable and never triggers +// re-renders by accident. +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const + +// Upper bound on "I fixed it, retry build" attempts after an AI diagnosis. +// Three total attempts (initial + two retries) caps the AI cost when a model +// suggestion doesn't actually fix the failure mode while still giving the user +// a couple of in-wizard chances to iterate. +const MAX_AI_RETRIES = 2 + const OUTPUT_LINE_SPLIT_RE = /\r?\n/ const CARRIAGE_RETURN_RE = /\r/g @@ -167,9 +171,11 @@ interface AppProps { apikey?: string // Capgo API gateway override (--supa-host); prod when omitted. supaHost?: string - /** Reports the wizard outcome to the shell when it reaches build-complete, so + /** + * Reports the wizard outcome to the shell when it reaches build-complete, so * the caller prints an accurate post-exit message + durable summary instead of - * always claiming success. Never fires on cancel/missing-platform exits. */ + * always claiming success. Never fires on cancel/missing-platform exits. + */ onResult?: (result: OnboardingResult) => void } @@ -976,7 +982,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres exitRequestedRef.current = true if (message) addLog(message, 'yellow') - setTimeout(() => exit(), 50) + setTimeout(exit, 50) }, [addLog, exit]) // Open browser on Ctrl+O (FilteredTextInput ignores ctrl keys, so no conflict) @@ -1059,7 +1065,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // a subsequent setStep on an alternate restart path) sees clean // values rather than stale. setP8Path('') - setP8Content('') // wrapper updates p8ContentRef too + setP8Content('') // wrapper updates p8ContentRef too setKeyId('') setIssuerId('') setTeamId('') @@ -1676,7 +1682,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres try { const raced = await Promise.race([ listProfilesForCert(token, certId), - new Promise<'__timeout__'>(resolve => setTimeout(() => resolve('__timeout__'), 7000)), + new Promise<'__timeout__'>(resolve => setTimeout(resolve, 7000, '__timeout__')), ]) // Check generation, NOT the step-tied `cancelled` — // setStep('import-pick-identity') below trips cancelled @@ -1886,6 +1892,42 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres })() } + if (step === 'asc-key-generating') { + ;(async () => { + try { + // Launch the guided macOS helper; it streams stats to PostHog and + // returns the captured credentials on its terminal result line. + const outcome = await runAscKeyHelper({ apikey }) + if (cancelled) + return + if (!outcome.ok) { + const reason = outcome.errorCode === 'USER_CANCELLED' + ? 'Guided key creation was cancelled.' + : `Guided key creation failed (${outcome.errorCode}): ${outcome.message}` + // Back to the fork so the user can retry, say "I have a .p8", or + // create it by hand. + handleError(new Error(reason), 'p8-source-select') + return + } + const { credentials } = outcome + setP8Content(credentials.privateKey) // wrapper also updates p8ContentRef + setKeyId(credentials.keyId) + setIssuerId(credentials.issuerId) + // The helper also saved the .p8 to the fastlane/ASC conventional path. + setP8Path(join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${credentials.keyId}.p8`)) + await savePartialProgress({ keyId: credentials.keyId, issuerId: credentials.issuerId }) + if (cancelled) + return + addLog('✔ App Store Connect API key created via guided helper') + setStep('verifying-key') + } + catch (err) { + if (!cancelled) + handleError(err, 'p8-source-select') + } + })() + } + // The legacy `import-fetching-profile` step (used by the "Rescan Apple // API" recovery option) was removed in favour of the per-identity // auto-fetch built into the upcoming `import-checking-apple-cert` step @@ -2320,7 +2362,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres if (cancelled) return setVerifyAppLoading(false) - addLog("⚠ Couldn't reach App Store Connect to verify your app; continuing without remote verification.", 'yellow') + addLog('⚠ Couldn\'t reach App Store Connect to verify your app; continuing without remote verification.', 'yellow') if (!verifyShownRef.current) { verifyShownRef.current = true trackVerifyEvent('iOS App Verify Shown', '🔍', { app_count: 0, bundle_id_count: 0, debug_release_differ: debugReleaseDiffer }) @@ -3153,7 +3195,7 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // The fullscreen AI viewer is a takeover: render it as an EARLY RETURN so it // owns the whole terminal and bypasses the regular wizard frame above. It // fills the screen itself via minHeight. - if (isAiResultScroll && aiAnalysisText) + if (isAiResultScroll && aiAnalysisText) { return ( = ({ appId, iosBundleIdInitial, initialProgres }} /> ) + } // "View logs first" from the support confirm — a scrollable takeover of the // exact bundle that will be sent (secrets already redacted). Exit returns to // the confirm so the user can then send or cancel. - if (step === 'support-log-view') + if (step === 'support-log-view') { return ( = ({ appId, iosBundleIdInitial, initialProgres onExit={() => setStep('support-confirm')} /> ) + } // The iOS error screen is a fullscreen scroll takeover when its recovery // advice is taller than the viewport — same treatment as the AI viewer above, // so the Try again / Restart / Exit actions (in the compact ErrorStep shown // after dismiss) are never pushed off-screen. Placed after the size gate like // the AI viewer: below the floor the resize prompt wins. - if (isErrorScroll && error) + if (isErrorScroll && error) { return ( = ({ appId, iosBundleIdInitial, initialProgres onExit={() => setErrorViewedFull(true)} /> ) + } // The workflow-file diff is a fullscreen takeover too (same reasoning as the // AI/build viewers): rendered inside the wizard Box it inherited the header + // padding (a large top gap) and a too-short viewport. As an early return it // owns the whole terminal and fills it. - if (step === 'view-workflow-diff' && previewDiff.length > 0) + if (step === 'view-workflow-diff' && previewDiff.length > 0) { return ( = ({ appId, iosBundleIdInitial, initialProgres }} /> ) + } // `minHeight={terminalRows}` makes the root fill the whole viewport. Ink // only does a full clear-the-screen redraw when the frame height is ≥ the @@ -3248,78 +3294,78 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres steps have completed. */} {/* Progress bar */} - {showProgress && ( - - {phaseLabel} - - - - {' '} - {progress} - % - + {showProgress && ( + + {phaseLabel} + + + + {' '} + {progress} + % + + + - - - )} + )} - {/* Resume-or-restart prompt — only reachable when initialProgress is + {/* Resume-or-restart prompt — only reachable when initialProgress is non-null AND getResumeStep didn't resolve to 'welcome'. The initial step useState above wires this branch. */} - {step === 'resume-prompt' && initialProgress && (() => { - const { startedAt, setupMethod, importDistribution: savedDist, completedSteps, iosBundleIdOverride } = initialProgress - // Defensive date parse: legacy / corrupted progress files can carry an - // unparseable startedAt — show the raw string with a dim suffix instead - // of crashing the wizard. - let whenLabel: string - try { - const d = new Date(startedAt) - if (Number.isNaN(d.getTime())) - throw new Error('NaN') - whenLabel = d.toLocaleString() - } - catch { - whenLabel = `${startedAt} (could not parse)` - } - const setupLabel = setupMethod === 'import-existing' - ? 'Import existing credentials' - : 'Create new via Apple' - const distLabel = savedDist === 'app_store' - ? 'App Store' - : savedDist === 'ad_hoc' ? 'Ad Hoc' : null - const keyVerified = Boolean(completedSteps.apiKeyVerified) - const certCreated = Boolean(completedSteps.certificateCreated) - const profileCreated = Boolean(completedSteps.profileCreated) - const showBundleOverride = Boolean( - iosBundleIdOverride && iosBundleIdOverride !== iosBundleIdInitial, - ) - const resumeLabel = getPhaseLabel(startStep) || startStep - return ( - - {`↩️ Found in-progress onboarding for ${appId}`} - Pick up where you left off, or start over from the welcome step. - - {`• Started: ${whenLabel}`} - {`• Setup method: ${setupLabel}`} - {distLabel && {`• Distribution mode: ${distLabel}`}} - {`• ASC API key verified: ${keyVerified ? `Yes (Key: ${completedSteps.apiKeyVerified!.keyId})` : 'No'}`} - {`• Certificate created: ${certCreated ? `Yes (expires ${completedSteps.certificateCreated!.expirationDate})` : 'No'}`} - {`• Profile created: ${profileCreated ? `Yes ("${completedSteps.profileCreated!.profileName}")` : 'No'}`} - {showBundleOverride && {`• Confirmed iOS bundle id: ${iosBundleIdOverride}`}} - {`• Resume target: ${resumeLabel}`} - - { // Record which branch the user took. The funnel already shows // the resume-prompt step + the next step (welcome on restart, // the resume target on continue), but the explicit choice tag // gives a clean continue-vs-restart split without inferring it. - trackAction('resume_prompt_decision', { choice: value }) - if (value === 'continue') { + trackAction('resume_prompt_decision', { choice: value }) + if (value === 'continue') { // Now that the user has committed to picking up where // they left off, replay the breadcrumb log so they see // the in-progress state they're resuming into. Held @@ -3327,539 +3373,581 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // wasn't surrounded by stale "Distribution · ad_hoc", // "Key file selected · …" entries while they were // still deciding. - hydrateCompletedLog() - setStep(startStep) - return - } - await resetForFreshStart() - addLog('↩️ Restarted — fresh start', 'yellow') - setStep('welcome') - }} - /> - - ) - })()} - - {/* Welcome */} - {step === 'welcome' && } - - {/* Platform select */} - {step === 'platform-select' && ( - { - if (value === 'android') { + hydrateCompletedLog() + setStep(startStep) + return + } + await resetForFreshStart() + addLog('↩️ Restarted — fresh start', 'yellow') + setStep('welcome') + }} + /> + + ) + })()} + + {/* Welcome */} + {step === 'welcome' && } + + {/* Platform select */} + {step === 'platform-select' && ( + { + if (value === 'android') { // The Android flow lives in a separate Ink app — this iOS app // can't host it inline. Exit cleanly and tell the user to // re-run with --platform android. - addLog('Re-run with `npx @capgo/cli@latest build init --platform android` to set up Android.', 'cyan') - exitOnboarding() - return - } - // Check for existing credentials before proceeding - const existing = await loadSavedCredentials(appId) - if (existing?.ios) { - setStep('credentials-exist') - } - else if (isMacOS()) { + addLog('Re-run with `npx @capgo/cli@latest build init --platform android` to set up Android.', 'cyan') + exitOnboarding() + return + } + // Check for existing credentials before proceeding + const existing = await loadSavedCredentials(appId) + if (existing?.ios) { + setStep('credentials-exist') + } + else if (isMacOS()) { // macOS users see the fork: import existing or create new - setStep('setup-method-select') - } - else { + setStep('setup-method-select') + } + else { // Non-macOS hosts can only create new (importing requires Keychain) - setStep('api-key-instructions') - } - }} - /> - )} - - {/* No platform directory */} - {step === 'no-platform' && ( - { - if (value === 'run') { - setStep('adding-platform') - } - else if (value === 'recheck') { - if (existsSync(join(process.cwd(), iosDir))) { - addLog(`✔ Found ${iosDir}/ — resuming onboarding.`) - ;(async () => { - const existing = await loadSavedCredentials(appId) - if (existing?.ios) - setStep('credentials-exist') - else - setStep('api-key-instructions') - })() + setStep('api-key-instructions') + } + }} + /> + )} + + {/* No platform directory */} + {step === 'no-platform' && ( + { + if (value === 'run') { + setStep('adding-platform') + } + else if (value === 'recheck') { + if (existsSync(join(process.cwd(), iosDir))) { + addLog(`✔ Found ${iosDir}/ — resuming onboarding.`) + ;(async () => { + const existing = await loadSavedCredentials(appId) + if (existing?.ios) + setStep('credentials-exist') + else + setStep('api-key-instructions') + })() + } + else { + addLog(`⚠ ${iosDir}/ is still missing. Try ${addIosCommand} or ${doctorCommand}.`, 'yellow') + } } else { - addLog(`⚠ ${iosDir}/ is still missing. Try ${addIosCommand} or ${doctorCommand}.`, 'yellow') + addLog(`Exiting. Run \`${buildInitCommand}\` after the native iOS folder exists.`, 'yellow') + exitOnboarding() } - } - else { - addLog(`Exiting. Run \`${buildInitCommand}\` after the native iOS folder exists.`, 'yellow') - exitOnboarding() - } - }} - /> - )} - - {step === 'adding-platform' && ( - - )} - - {/* Existing credentials warning */} - {step === 'credentials-exist' && ( - { - if (value === 'backup') { - setStep('backing-up') - } - else { - addLog('Exiting onboarding.', 'yellow') - exitOnboarding() - } - }} - /> - )} - - {/* Backing up credentials */} - {step === 'backing-up' && } - - {/* Setup-method fork (macOS only) */} - {step === 'setup-method-select' && ( - { + }} + /> + )} + + {step === 'adding-platform' && ( + + )} + + {/* Existing credentials warning */} + {step === 'credentials-exist' && ( + { + if (value === 'backup') { + setStep('backing-up') + } + else { + addLog('Exiting onboarding.', 'yellow') + exitOnboarding() + } + }} + /> + )} + + {/* Backing up credentials */} + {step === 'backing-up' && } + + {/* Setup-method fork (macOS only) */} + {step === 'setup-method-select' && ( + { // Persist the fork choice to progress so resume after CLI close // routes to the right path. Without this, an interrupted import // run resumes into the create-new path's `creating-certificate` // step and triggers the cert-limit error. - const existing = await loadProgress(appId) || { - platform: 'ios' as const, - appId, - startedAt: new Date().toISOString(), - completedSteps: {}, - } - existing.setupMethod = value === 'import' ? 'import-existing' : 'create-new' - await saveProgress(appId, existing) + const existing = await loadProgress(appId) || { + platform: 'ios' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + existing.setupMethod = value === 'import' ? 'import-existing' : 'create-new' + await saveProgress(appId, existing) - if (value === 'import') { - setImportMode(true) - setStep('import-scanning') - } - else { - setImportMode(false) - setStep('api-key-instructions') - } - }} - /> - )} + if (value === 'import') { + setImportMode(true) + setStep('import-scanning') + } + else { + setImportMode(false) + // Ask whether they already have a .p8 before the instructions. + setStep('p8-source-select') + } + }} + /> + )} - {/* Import: scanning */} - {step === 'import-scanning' && } + {/* Do you already have a .p8 file? */} + {step === 'p8-source-select' && ( + { + if (value === 'have') { + setStep('api-key-instructions') + } + else if (isMacOS() && resolveHelperBinary() !== null) { + // No key yet, and we can drive the guided helper. + setStep('p8-create-method-select') + } + else { + // No automation available — fall back to the manual instructions. + setStep('api-key-instructions') + } + }} + /> + )} - {/* Verify the App Store Connect app exists for the Release build id. - app_store mode only; reached after verifying-key. The single - invariant: an ASC app exists whose bundleId - == the Release PRODUCT_BUNDLE_IDENTIFIER. The exact-match and - fetch-failure cases are handled in the effect above (they transition - straight through); this render only drives the picker + the two - resolution-path gates. */} - {step === 'verify-app' && ( - - {verifyDebugBundleId && verifyReleaseBundleId - ? ( - - {'⚠ Debug and Release use different bundle IDs'} - - {'Debug builds '} - {verifyDebugBundleId} - {' · Release builds '} - {verifyReleaseBundleId} - - - {'Capgo Builder signs the '} - Release - {' ID → '} - {verifyReleaseBundleId} - - - ) - : null} - {(() => { - if (verifyAppLoading) { - return ( - - - Fetching your apps and registered bundle IDs to verify the build matches a real App Store app. - - ) - } + {/* How to create the .p8: guided helper vs by hand (macOS only) */} + {step === 'p8-create-method-select' && ( + { + if (value === 'automated') + setStep('asc-key-generating') + else + setStep('api-key-instructions') + }} + /> + )} - const releaseId = verifyReleaseBundleId + {/* Guided helper is running in its own window */} + {step === 'asc-key-generating' && } - // Final pass: persist the verified Release id as the override and - // continue to the pending target (creating-certificate). - const passGate = async (path: GatePath, resolvedId: string) => { - await persistVerifyOverride(resolvedId) - trackVerifyEvent('iOS App Verify Passed', '✅', { attempts: verifyAttempt, path }) - setStep(pendingVerifyNext ?? 'creating-certificate') - setPendingVerifyNext(null) - } + {/* Import: scanning */} + {step === 'import-scanning' && } - // Path A Continue — re-read pbxproj FRESH from disk (never the memo) - // and re-check the Release build id against the chosen app. - const continueFixBuildId = async () => { - const fresh = detectIosBundleIds({ cwd: process.cwd(), iosDir, capacitorAppId: iosBundleIdInitial }) - const newRelease = fresh.releaseResolved && fresh.pbxproj ? fresh.pbxproj.value : '' - setVerifyReleaseBundleId(newRelease) - const satisfied = Boolean(verifyChosenApp) && newRelease === verifyChosenApp!.bundleId - const attempt = verifyAttempt + 1 - const gate = evaluateGate({ satisfied, attempt }) - if (gate.proceed) { - addLog(`✓ Building "${verifyChosenApp!.name}" (${newRelease}) — matches your App Store app.`) - await passGate('fix-build-id', newRelease) - return - } - setVerifyAttempt(attempt) - trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'fix-build-id' }) - } + {/* Verify the App Store Connect app exists for the Release build id. + app_store mode only; reached after verifying-key. The single + invariant: an ASC app exists whose bundleId + == the Release PRODUCT_BUNDLE_IDENTIFIER. The exact-match and + fetch-failure cases are handled in the effect above (they transition + straight through); this render only drives the picker + the two + resolution-path gates. */} + {step === 'verify-app' && ( + + {verifyDebugBundleId && verifyReleaseBundleId + ? ( + + ⚠ Debug and Release use different bundle IDs + + {'Debug builds '} + {verifyDebugBundleId} + {' · Release builds '} + {verifyReleaseBundleId} + + + {'Capgo Builder signs the '} + Release + {' ID → '} + {verifyReleaseBundleId} + + + ) + : null} + {(() => { + if (verifyAppLoading) { + return ( + + + Fetching your apps and registered bundle IDs to verify the build matches a real App Store app. + + ) + } - // Path A auto-fix — rewrite the Release PRODUCT_BUNDLE_IDENTIFIER in the - // Xcode project to the chosen App Store app's bundle id, then re-check - // (which now passes and advances the gate). Only PRODUCT_BUNDLE_IDENTIFIER - // assignments equal to the current build id are touched; capacitor.config - // is never modified. - const autoFixBuildId = async () => { - if (!verifyChosenApp) - return - const target = verifyChosenApp.bundleId - try { - const { changed } = writeReleaseBundleId(process.cwd(), iosDir, releaseId, target) - if (changed > 0) { - addLog(`🔧 Updated PRODUCT_BUNDLE_IDENTIFIER → "${target}" in your Xcode project.`) - trackVerifyEvent('iOS App Verify Auto Fixed', '🔧', { attempt: verifyAttempt, path: 'fix-build-id' }) - } - else { - addLog(`⚠ Couldn't find PRODUCT_BUNDLE_IDENTIFIER "${releaseId}" to update — edit it in Xcode, then re-check.`, 'yellow') - } - } - catch { - addLog('⚠ Could not write to your Xcode project — edit PRODUCT_BUNDLE_IDENTIFIER manually, then re-check.', 'yellow') - } - // Re-check against disk; passes the gate when the write succeeded. - await continueFixBuildId() - } + const releaseId = verifyReleaseBundleId - // Path B Continue — re-poll /v1/apps and check for an app matching the - // Release build id. Never re-opens the browser automatically. - const continueCreateApp = async () => { - // Show the step's loader while we re-poll ASC (an async network call) — - // otherwise the re-check feels instant and the user can't tell it ran. - setVerifyAppLoading(true) - const attempt = verifyAttempt + 1 - try { - const token = await getFreshToken() - const apps = await listApps(token) - setVerifyApps(apps) - const satisfied = apps.some(a => a.bundleId === releaseId) - if (evaluateGate({ satisfied, attempt }).proceed) { - const matched = apps.find(a => a.bundleId === releaseId) - addLog(`✓ Building "${matched?.name ?? releaseId}" (${releaseId}) — matches your App Store app.`) - await passGate('create-app', releaseId) - return - } - // Still not found — count the attempt so the escalating box visibly - // advances, then ask before re-opening the browser. - setVerifyAttempt(attempt) - setVerifyAskReopen(true) - trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'create-app' }) - } - catch { - // Couldn't reach ASC — still count the attempt so the user sees the - // re-check happened (not a silent no-op) and surface a connectivity - // message distinct from "app still missing". - setVerifyAttempt(attempt) - setVerifyAskReopen(true) - addLog("⚠ Couldn't reach App Store Connect to re-check — check your connection and try again.", 'yellow') - trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'create-app' }) - } - finally { - setVerifyAppLoading(false) - } - } + // Final pass: persist the verified Release id as the override and + // continue to the pending target (creating-certificate). + const passGate = async (path: GatePath, resolvedId: string) => { + await persistVerifyOverride(resolvedId) + trackVerifyEvent('iOS App Verify Passed', '✅', { attempts: verifyAttempt, path }) + setStep(pendingVerifyNext ?? 'creating-certificate') + setPendingVerifyNext(null) + } - // Open the ASC new-app page. Registers the identifier first (idempotent) - // so it is selectable in the form. Opens ONLY on explicit choice. - const openCreatePage = async () => { - try { - const token = await getFreshToken() - await ensureBundleId(token, releaseId) - } - catch { - // Registration is best-effort — the user can still create the app - // and pick/register the id in the web form. - } - trackVerifyEvent('iOS App Verify Create App Opened', '🌐', { attempt: verifyAttempt }) - try { - await open('https://appstoreconnect.apple.com/apps') - } - catch { - addLog('⚠ Could not open your browser. Visit https://appstoreconnect.apple.com/apps to create the app.', 'yellow') - } - setVerifyAskReopen(false) - } + // Path A Continue — re-read pbxproj FRESH from disk (never the memo) + // and re-check the Release build id against the chosen app. + const continueFixBuildId = async () => { + const fresh = detectIosBundleIds({ cwd: process.cwd(), iosDir, capacitorAppId: iosBundleIdInitial }) + const newRelease = fresh.releaseResolved && fresh.pbxproj ? fresh.pbxproj.value : '' + setVerifyReleaseBundleId(newRelease) + const satisfied = Boolean(verifyChosenApp) && newRelease === verifyChosenApp!.bundleId + const attempt = verifyAttempt + 1 + const gate = evaluateGate({ satisfied, attempt }) + if (gate.proceed) { + addLog(`✓ Building "${verifyChosenApp!.name}" (${newRelease}) — matches your App Store app.`) + await passGate('fix-build-id', newRelease) + return + } + setVerifyAttempt(attempt) + trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'fix-build-id' }) + } - const cancelGate = (path: GatePath) => { - trackVerifyEvent('iOS App Verify Cancelled', '🚫', { attempt: verifyAttempt, path }) - addLog('Exiting onboarding.', 'yellow') - exitOnboarding() - } + // Path A auto-fix — rewrite the Release PRODUCT_BUNDLE_IDENTIFIER in the + // Xcode project to the chosen App Store app's bundle id, then re-check + // (which now passes and advances the gate). Only PRODUCT_BUNDLE_IDENTIFIER + // assignments equal to the current build id are touched; capacitor.config + // is never modified. + const autoFixBuildId = async () => { + if (!verifyChosenApp) + return + const target = verifyChosenApp.bundleId + try { + const { changed } = writeReleaseBundleId(process.cwd(), iosDir, releaseId, target) + if (changed > 0) { + addLog(`🔧 Updated PRODUCT_BUNDLE_IDENTIFIER → "${target}" in your Xcode project.`) + trackVerifyEvent('iOS App Verify Auto Fixed', '🔧', { attempt: verifyAttempt, path: 'fix-build-id' }) + } + else { + addLog(`⚠ Couldn't find PRODUCT_BUNDLE_IDENTIFIER "${releaseId}" to update — edit it in Xcode, then re-check.`, 'yellow') + } + } + catch { + addLog('⚠ Could not write to your Xcode project — edit PRODUCT_BUNDLE_IDENTIFIER manually, then re-check.', 'yellow') + } + // Re-check against disk; passes the gate when the write succeeded. + await continueFixBuildId() + } - // Return to the app picker (verifyPath === null) to choose a different - // App Store app or switch to "create a new app". Resets the per-attempt - // gate state so the re-picked target starts fresh. - const backToPicker = () => { - setVerifyPath(null) - setVerifyChosenApp(null) - setVerifyAttempt(0) - setVerifyAskReopen(false) - } + // Path B Continue — re-poll /v1/apps and check for an app matching the + // Release build id. Never re-opens the browser automatically. + const continueCreateApp = async () => { + // Show the step's loader while we re-poll ASC (an async network call) — + // otherwise the re-check feels instant and the user can't tell it ran. + setVerifyAppLoading(true) + const attempt = verifyAttempt + 1 + try { + const token = await getFreshToken() + const apps = await listApps(token) + setVerifyApps(apps) + const satisfied = apps.some(a => a.bundleId === releaseId) + if (evaluateGate({ satisfied, attempt }).proceed) { + const matched = apps.find(a => a.bundleId === releaseId) + addLog(`✓ Building "${matched?.name ?? releaseId}" (${releaseId}) — matches your App Store app.`) + await passGate('create-app', releaseId) + return + } + // Still not found — count the attempt so the escalating box visibly + // advances, then ask before re-opening the browser. + setVerifyAttempt(attempt) + setVerifyAskReopen(true) + trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'create-app' }) + } + catch { + // Couldn't reach ASC — still count the attempt so the user sees the + // re-check happened (not a silent no-op) and surface a connectivity + // message distinct from "app still missing". + setVerifyAttempt(attempt) + setVerifyAskReopen(true) + addLog('⚠ Couldn\'t reach App Store Connect to re-check — check your connection and try again.', 'yellow') + trackVerifyEvent('iOS App Verify Gate Blocked', '🚧', { attempt, path: 'create-app' }) + } + finally { + setVerifyAppLoading(false) + } + } - // Escalating border colour ramp so a repeatedly-blocked gate never - // looks frozen (spec: each blocked Continue must look visibly - // different). Tops out at red. - const escalation = evaluateGate({ satisfied: false, attempt: verifyAttempt }).escalationLevel - const gateBorder = escalation >= 3 ? 'red' : escalation === 2 ? 'yellow' : 'cyan' - const attemptMarker = verifyAttempt > 0 ? ` (attempt ${verifyAttempt})` : '' - - // ── Path A: fix the build id ────────────────────────────────────── - if (verifyPath === 'fix-build-id' && verifyChosenApp) { - const wrong = releaseId - const right = verifyChosenApp.bundleId - return ( - - - {`Build ID doesn't match "${verifyChosenApp.name}"${attemptMarker}`} - - - {'Your project builds '} - {wrong || '(no Release build ID resolved)'} - {', but the App Store app you picked is '} - {right} - {'.'} - - - - {'Set '} - PRODUCT_BUNDLE_IDENTIFIER - {' (Release) to '} - {right} - {' — pick "Update … for me" below to do it automatically, or edit it in Xcode yourself and re-check.'} - - {'capacitor.config.appId can stay as-is — only the Release PRODUCT_BUNDLE_IDENTIFIER must match.'} - - - { + setGateActionSeq(s => s + 1) + if (value === 'autofix') + void autoFixBuildId() + else if (value === 'continue') + void continueFixBuildId() + else if (value === 'back') + backToPicker() + else + cancelGate('fix-build-id') + }} + /> + + ) + } + + // ── Path B: create the app ──────────────────────────────────────── + if (verifyPath === 'create-app') { + const alreadyRegistered = verifyRegisteredIds.includes(releaseId) + // After a blocked re-poll we ASK before re-opening the browser. + if (verifyAskReopen) { + return ( + + + {`Still no App Store app for ${releaseId}${attemptMarker}`} + + + {`We re-checked App Store Connect and didn't find an app whose bundle ID is `} + {releaseId} + . + + Create the app on appstoreconnect.com (the API cannot create apps), then re-check. + + + 0 ? [{ label: '↩ Back — pick an existing app', value: 'back' }] : []), + { label: '❌ Cancel onboarding', value: 'cancel' }, + ]} + onChange={(value) => { + setGateActionSeq(s => s + 1) + if (value === 'open') + void openCreatePage() + else if (value === 'recheck') + void continueCreateApp() + else if (value === 'back') + backToPicker() + else + cancelGate('create-app') + }} + /> + + ) + } + + // ── Picker (wrong-build-id): account has apps, none match the build + // id. Let the user pick the intended app (→ Path A) or declare the + // build id correct and create a new app (→ Path B). ────────────── + return ( + + + {`No App Store app matches the bundle ID your project builds (${releaseId}).`} + + + + {`An app_store build signs the Release PRODUCT_BUNDLE_IDENTIFIER and uploads to the App Store app with the same bundle ID. None of your apps use ${releaseId}, so the upload would be rejected. Which app are you building?`} + - {`We re-checked App Store Connect and didn't find an app whose bundle ID is `}{releaseId}{'.'} - {'Create the app on appstoreconnect.com (the API cannot create apps), then re-check.'} + { - setGateActionSeq(s => s + 1) - if (value === 'recheck') - void continueCreateApp() - else if (value === 'reopen') - void openCreatePage() - else - cancelGate('create-app') - }} - /> - - ) - } - return ( - - - {`No App Store app exists for ${releaseId}${attemptMarker}`} - - - {'Your project builds '} - {releaseId} - {`, but there's no App Store Connect app with that bundle ID yet. An app_store build needs one to upload to.`} - - - - {alreadyRegistered - ? `The identifier ${releaseId} is already registered in your Developer account — select it when creating the app.` - : `We'll register the identifier ${releaseId} (so it's selectable) when you open the create-app page.`} - - {'The App Store Connect API cannot create apps — this is a one-time manual step on the web.'} - - - ({ - label: `${a.name} — ${a.bundleId}`, - value: a.bundleId, - })), - { label: '➕ None of these — my build ID is correct, create a new app', value: '__create_new__' }, - ]} - onChange={(value) => { - if (value === '__create_new__') { - trackVerifyEvent('iOS App Verify Picked', '👆', { matches_build_id: false, chose_create_new: true }) - setVerifyPath('create-app') - return - } - const chosen = verifyApps.find(a => a.bundleId === value) ?? null - trackVerifyEvent('iOS App Verify Picked', '👆', { - matches_build_id: value === releaseId, - chose_create_new: false, - }) - if (chosen && chosen.bundleId === releaseId) { - // Already matches — pass straight through (defensive; the - // exact-match case is normally handled in the effect). - addLog(`✓ Building "${chosen.name}" (${releaseId}) — matches your App Store app.`) - void (async () => { - try { - await passGate('fix-build-id', releaseId) - } - catch { - addLog('⚠ Could not save the verified bundle ID; you may be re-prompted next run.', 'yellow') - } - })() - return + {/* Import: distribution mode (now FIRST visible step in import flow) */} + {step === 'import-distribution-mode' && ( + { + if (value === '__cancel__') { + setImportMode(false) + // Clear the persisted import-distribution and setupMethod since + // the user is bailing to the create-new path. + const existing = await loadProgress(appId) + if (existing) { + existing.setupMethod = 'create-new' + delete existing.importDistribution + await saveProgress(appId, existing) } - setVerifyChosenApp(chosen) - setVerifyPath('fix-build-id') - }} - /> - - ) - })()} - - )} - - {/* Import: distribution mode (now FIRST visible step in import flow) */} - {step === 'import-distribution-mode' && ( - { - if (value === '__cancel__') { - setImportMode(false) - // Clear the persisted import-distribution and setupMethod since - // the user is bailing to the create-new path. - const existing = await loadProgress(appId) - if (existing) { - existing.setupMethod = 'create-new' - delete existing.importDistribution - await saveProgress(appId, existing) + setStep('api-key-instructions') + return } - setStep('api-key-instructions') - return - } - const mode = value as 'app_store' | 'ad_hoc' - setImportDistribution(mode) - // Persist so a CLI restart at any later step (incl. verifying-key - // or saving-credentials) knows we're in app_store vs ad_hoc. - // Codex caught a bug where without this, resumed sessions - // re-entered the create-new path via the stale `importMode=false` - // default — fixed here by hydrating both fields on mount. - const existing = await loadProgress(appId) || { - platform: 'ios' as const, - appId, - startedAt: new Date().toISOString(), - completedSteps: {}, - } - existing.setupMethod = 'import-existing' - existing.importDistribution = mode - await saveProgress(appId, existing) - upsertLog('✔ Distribution · ', `✔ Distribution · ${mode}`) - if (mode === 'app_store') { + const mode = value as 'app_store' | 'ad_hoc' + setImportDistribution(mode) + // Persist so a CLI restart at any later step (incl. verifying-key + // or saving-credentials) knows we're in app_store vs ad_hoc. + // Codex caught a bug where without this, resumed sessions + // re-entered the create-new path via the stale `importMode=false` + // default — fixed here by hydrating both fields on mount. + const existing = await loadProgress(appId) || { + platform: 'ios' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + existing.setupMethod = 'import-existing' + existing.importDistribution = mode + await saveProgress(appId, existing) + upsertLog('✔ Distribution · ', `✔ Distribution · ${mode}`) + if (mode === 'app_store') { // Need .p8 for TestFlight upload AND for any profile auto-recovery. // After verifying-key the import-mode branch routes back to import-pick-identity. // Skip the .p8 input chain entirely if the key was already @@ -3867,9 +3955,9 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // re-ask "How do you want to provide the .p8 file?" even // though APPLE_KEY_CONTENT is already known. Use the same // routing decision as the post-scan entry point. - setStep(getImportEntryStep(existing)) - } - else { + setStep(getImportEntryStep(existing)) + } + else { // ad_hoc skips .p8; can opt into it later from no-match recovery. // Surface the support hint up-front rather than waiting until // the user is mid-recovery in the portal-explanation step — @@ -3877,325 +3965,325 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres // know help is available before they hit a wall. The helper // is idempotent across the session, so re-picking Ad Hoc // after a back-navigation doesn't re-emit. - logAdHocSupportHint() - setStep('import-pick-identity') - } - }} - /> - )} - - {/* Import: validating all certs with Apple — eager batch */} - {step === 'import-validating-all-certs' && ( - - - Splitting into Available / Unavailable so we only offer options that can succeed. - - )} - - {/* Import: per-identity Apple check + auto-fetch profile */} - {step === 'import-checking-apple-cert' && chosenIdentity && ( - - - Looking up the cert + listing its profiles so we either auto-import or only show recovery options that can succeed. - - )} - - {/* Import: pick identity — two-table picker (Available + Unavailable) + logAdHocSupportHint() + setStep('import-pick-identity') + } + }} + /> + )} + + {/* Import: validating all certs with Apple — eager batch */} + {step === 'import-validating-all-certs' && ( + + + Splitting into Available / Unavailable so we only offer options that can succeed. + + )} + + {/* Import: per-identity Apple check + auto-fetch profile */} + {step === 'import-checking-apple-cert' && chosenIdentity && ( + + + Looking up the cert + listing its profiles so we either auto-import or only show recovery options that can succeed. + + )} + + {/* Import: pick identity — two-table picker (Available + Unavailable) when we have classification data from import-validating-all-certs; falls back to main's flat list when not. */} - {step === 'import-pick-identity' && (() => { - const haveClassification = Object.keys(identityAvailability).length > 0 - // Partition identities. When the batch validation didn't run - // (no API key yet) every identity lands in "available" so the - // user gets the unfiltered list — they can still recover from - // each pick via the no-match menu / per-identity check. - const available: IdentityProfileMatch[] = [] - const unavailable: IdentityProfileMatch[] = [] - for (const m of importMatches) { - const a = identityAvailability[m.identity.sha1] - if (!haveClassification || a?.available) - available.push(m) - else - unavailable.push(m) - } + {step === 'import-pick-identity' && (() => { + const haveClassification = Object.keys(identityAvailability).length > 0 + // Partition identities. When the batch validation didn't run + // (no API key yet) every identity lands in "available" so the + // user gets the unfiltered list — they can still recover from + // each pick via the no-match menu / per-identity check. + const available: IdentityProfileMatch[] = [] + const unavailable: IdentityProfileMatch[] = [] + for (const m of importMatches) { + const a = identityAvailability[m.identity.sha1] + if (!haveClassification || a?.available) + available.push(m) + else + unavailable.push(m) + } - const onPick = async (value: string) => { - if (value === '__cancel__') { - setImportMode(false) - const existing = await loadProgress(appId) - if (existing) { - existing.setupMethod = 'create-new' - delete existing.importDistribution - await saveProgress(appId, existing) + const onPick = async (value: string) => { + if (value === '__cancel__') { + setImportMode(false) + const existing = await loadProgress(appId) + if (existing) { + existing.setupMethod = 'create-new' + delete existing.importDistribution + await saveProgress(appId, existing) + } + setStep('api-key-instructions') + return } - setStep('api-key-instructions') - return + const match = importMatches.find(m => m.identity.sha1 === value) + if (!match) + return + setChosenIdentity(match.identity) + // Clear stale per-identity cert id from a previous pick so the + // per-identity check doesn't trust an old result. + setAppleCertIdForChosen(undefined) + addLog(`✔ Identity · ${match.identity.name}`) + // Three-way routing: + // - Local profile already matches → straight to picker + // - No local match but ASC key available + cert is verified + // by the batch validation → per-identity check (auto-fetch + // from Apple before showing recovery menu) + // - Otherwise → straight to recovery menu + const usable = filterProfilesForApp(match.profiles, iosBundleId, importDistribution) + if (usable.length > 0) { + setStep('import-pick-profile') + return + } + const apiKeyAvailable = !!(p8ContentRef.current || (await loadProgress(appId))?.completedSteps?.apiKeyVerified) + if (!apiKeyAvailable) + setNoMatchReason('no-profile-on-disk') + setStep(apiKeyAvailable ? 'import-checking-apple-cert' : 'import-no-match-recovery') } - const match = importMatches.find(m => m.identity.sha1 === value) - if (!match) - return - setChosenIdentity(match.identity) - // Clear stale per-identity cert id from a previous pick so the - // per-identity check doesn't trust an old result. - setAppleCertIdForChosen(undefined) - addLog(`✔ Identity · ${match.identity.name}`) - // Three-way routing: - // - Local profile already matches → straight to picker - // - No local match but ASC key available + cert is verified - // by the batch validation → per-identity check (auto-fetch - // from Apple before showing recovery menu) - // - Otherwise → straight to recovery menu - const usable = filterProfilesForApp(match.profiles, iosBundleId, importDistribution) - if (usable.length > 0) { - setStep('import-pick-profile') - return + + // When classification ran we render two tables + a Select with + // available rows only (unavailable certs can't be picked). Without + // classification we fall back to main's flat-list ImportPickIdentityStep + // so nothing regresses for the ad_hoc-without-.p8 entry path. + if (!haveClassification) { + return ( + { + const matchCount = filterProfilesForApp(m.profiles, iosBundleId, importDistribution).length + const label = matchCount > 0 + ? `🔑 ${m.identity.name} · ${matchCount} matching profile${matchCount === 1 ? '' : 's'}` + : `🔑 ${m.identity.name} · ⚠ no matching profiles on this Mac (recovery available)` + return { label, value: m.identity.sha1 } + }), + { label: '↩️ Cancel and use Create new instead', value: '__cancel__' }, + ]} + onChange={onPick} + /> + ) } - const apiKeyAvailable = !!(p8ContentRef.current || (await loadProgress(appId))?.completedSteps?.apiKeyVerified) - if (!apiKeyAvailable) - setNoMatchReason('no-profile-on-disk') - setStep(apiKeyAvailable ? 'import-checking-apple-cert' : 'import-no-match-recovery') - } - // When classification ran we render two tables + a Select with - // available rows only (unavailable certs can't be picked). Without - // classification we fall back to main's flat-list ImportPickIdentityStep - // so nothing regresses for the ad_hoc-without-.p8 entry path. - if (!haveClassification) { + const availableRows = available.map((m, i) => { + const matchCount = filterProfilesForApp(m.profiles, iosBundleId, importDistribution).length + // Cell value is a three-way state: + // 1. matchCount > 0 → 'AVAILABLE' (green). Catches BOTH the + // on-disk-match case AND the prefetch-injected case (the + // prefetch synthesises profiles into m.profiles, so this + // branch lights up automatically — no separate prefetch + // branch needed for the success path). + // 2. matchCount === 0 + prefetch pending → animated spinner. + // Yellow cellColor (see Table below) signals "in flight, + // click is still allowed" — the onPick handler routes + // pending clicks the same way unavailable clicks go, + // through import-checking-apple-cert. + // 3. matchCount === 0 + timeout/error/unavailable/no entry → + // 'UNAVAILABLE' (red). Click re-routes through + // import-checking-apple-cert so the user gets a fresh fetch. + const prefetchState = profilePrefetch[m.identity.sha1] + let profileCell: string + if (matchCount > 0) + profileCell = 'AVAILABLE' + else if (prefetchState?.kind === 'pending') + profileCell = `${SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length]} checking…` + else + profileCell = 'UNAVAILABLE' + return { + '#': `${i + 1}`, + 'Name': `🔑 ${m.identity.name}`, + 'Team': m.identity.teamId, + 'Profile': profileCell, + } + }) + const unavailableRows = unavailable.map(m => ({ + Name: `🔒 ${m.identity.name}`, + Team: m.identity.teamId, + Reason: identityAvailability[m.identity.sha1]?.reasonText || 'Not classified', + })) + return ( - + {available.length > 0 && ( + <> + {`✅ CERTIFICATE${available.length === 1 ? '' : 'S'} AVAILABLE (${available.length})`} + +
{ + if (col !== 'Profile') + return undefined + if (val === 'AVAILABLE') + return 'green' + if (val === 'UNAVAILABLE') + return 'red' + // Spinner cell — every pending render is one of + // SPINNER_FRAMES followed by ` checking…`. Endswith is + // cheaper than scanning frame chars and tolerates the + // frame index rolling over between renders. + if (typeof val === 'string' && val.endsWith(' checking…')) + return 'yellow' + return undefined + }} + /> + + + )} + {available.length === 0 && ( + + ✖ NO CERTIFICATES AVAILABLE + + All + {' '} + {unavailable.length} + {' '} + identit + {unavailable.length === 1 ? 'y is' : 'ies are'} + {' '} + unavailable on Apple's side. See the table below for the reason, or use "Create new" to generate a fresh cert + profile. + + + + )} + {unavailable.length > 0 && ( + <> + {`✖ CERTIFICATE${unavailable.length === 1 ? '' : 'S'} UNAVAILABLE (${unavailable.length})`} + +
(col === 'Reason' ? 'yellow' : undefined)} + cellDim={col => col !== 'Reason'} + /> + + + )} + Pick an option: +
{ - if (col !== 'Profile') - return undefined - if (val === 'AVAILABLE') - return 'green' - if (val === 'UNAVAILABLE') - return 'red' - // Spinner cell — every pending render is one of - // SPINNER_FRAMES followed by ` checking…`. Endswith is - // cheaper than scanning frame chars and tolerates the - // frame index rolling over between renders. - if (typeof val === 'string' && val.endsWith(' checking…')) - return 'yellow' - return undefined - }} - /> - - - )} - {available.length === 0 && ( - - {`✖ NO CERTIFICATES AVAILABLE`} - - All - {' '} - {unavailable.length} - {' '} - identit - {unavailable.length === 1 ? 'y is' : 'ies are'} - {' '} - unavailable on Apple's side. See the table below for the reason, or use "Create new" to generate a fresh cert + profile. - - - - )} - {unavailable.length > 0 && ( - <> - {`✖ CERTIFICATE${unavailable.length === 1 ? '' : 'S'} UNAVAILABLE (${unavailable.length})`} - -
(col === 'Reason' ? 'yellow' : undefined)} - cellDim={col => col !== 'Reason'} - /> - - - )} - Pick an option: - { - if (value === 'use-create') { - setStep('import-create-profile-only') - return - } - if (value === 'use-file') { - mobileprovisionPickerOpenedRef.current = false - setStep('import-provide-profile-path') - return - } - if (value === 'open-anyway') { - open('https://developer.apple.com/account/resources/profiles/list') - addLog('🌐 Opened Apple Developer Portal — once you have downloaded the .mobileprovision file, come back and pick "📁 Use a .mobileprovision file from disk".', 'yellow') + + {canAutoCreate && ( + + 💡 Recommended: let Capgo do this automatically. + + "✨ Create a new App Store profile for this cert via Apple" runs the same steps via the Apple API — same cert, same bundle ID, no portal navigation, no manual download. + + + + )} + + )} + + {/* Issuer ID */} + {step === 'input-issuer-id' && ( + { + const cleaned = value.trim() + if (!cleaned) + return + setIssuerId(cleaned) + upsertLog('✔ Issuer ID · ', `✔ Issuer ID · ${cleaned}`) + void savePartialProgress({ issuerId: cleaned }) + setStep('verifying-key') + }} + /> + )} + + {/* Verifying */} + {step === 'verifying-key' && } + + {/* Creating certificate */} + {step === 'creating-certificate' && } + + {/* Certificate limit — ask which to revoke */} + {step === 'cert-limit-prompt' && ( + { + const ourCertId = certData?.certificateId || initialProgress?.completedSteps.certificateCreated?.certificateId + const isOurs = ourCertId === c.id + const creator = isOurs ? ' · 🔧 Created by Capgo' : '' + return { + label: `🗑️ ${c.name} · expires ${c.expirationDate.split('T')[0]}${creator}`, + value: c.id, + } + }), + { label: '✖ Exit onboarding', value: '__exit__' }, ]} onChange={(value) => { - if (value === 'no') { - setSetupMode('declined') - setStep('ask-export-env') - return + if (value === '__exit__') { + addLog(`Exiting. Revoke a certificate manually in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') + exitOnboarding() + } + else { + setCertToRevoke(value) + setStep('revoking-certificate') + } + }} + /> + )} + + {/* Revoking certificate */} + {step === 'revoking-certificate' && } + + {/* Creating profile */} + {step === 'creating-profile' && } + + {/* Duplicate profile prompt */} + {step === 'duplicate-profile-prompt' && ( + { + if (value === 'delete') { + setStep('deleting-duplicate-profiles') + } + else { + addLog(`Exiting. Delete the duplicate profiles in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') + exitOnboarding() } - setSetupMode(value as 'with-workflow' | 'secrets-only') - setStep('checking-ci-secrets') }} /> - - )} - - {step === 'ask-export-env' && ( - - Export the credentials as a .env file instead? - - Writes - {' '} - {defaultExportPath(appId, 'ios').split('/').slice(-1)[0]} - {' '} - so you can wire up CI later via - {' '} - gh secret set -f - {' '} - or paste the values manually. - - - { - setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') + setStep(value === 'yes' ? 'checking-ci-secrets' : 'build-complete') }} /> - - )} - - {step === 'pick-package-manager' && (() => { - const detected = normalizePackageManager(pm.pm) - const detectionNote = pm.pm === 'unknown' - ? '(no recognizable lockfile in this project — pick whichever you actually use)' - : `(detected from your lockfile — ${pm.pm})` - return ( + )} + + {step === 'ask-github-actions-setup' && ( - Which package manager does this project use? + + + Set up GitHub Actions for you? - Drives the install + build steps in the generated workflow. We + Capgo can push your {' '} - {detectionNote} + {ciSecretEntries.length} + {' '} + build env var + {ciSecretEntries.length === 1 ? '' : 's'} + {' '} + as repository secrets and drop a + {' '} + .github/workflows/capgo-build.yml file you can dispatch manually. { - if (value === '__skip__') { - setBuildScriptChoice({ type: 'skip' }) - setStep('preview-workflow-file') - return - } - if (value === '__custom__') { - setStep('pick-build-script-custom') - return - } - setBuildScriptChoice({ type: 'npm-script', name: value }) - setStep('preview-workflow-file') - }} - /> - - )} - - {step === 'pick-build-script-custom' && ( - - Custom build command - - Type the exact command you want the workflow to run before - {' '} - capgo build request - {' '} - (e.g. - {' '} - make web - , - {' '} - bash scripts/build.sh - ). - - - { - const cleaned = value.trim() - if (!cleaned) + )} + + {step === 'ask-export-env' && ( + + Export the credentials as a .env file instead? + + Writes + {' '} + {defaultExportPath(appId, 'ios').split('/').slice(-1)[0]} + {' '} + so you can wire up CI later via + {' '} + gh secret set -f + {' '} + or paste the values manually. + + + { + setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') + }} + /> + + )} + + {step === 'pick-package-manager' && (() => { + const detected = normalizePackageManager(pm.pm) + const detectionNote = pm.pm === 'unknown' + ? '(no recognizable lockfile in this project — pick whichever you actually use)' + : `(detected from your lockfile — ${pm.pm})` + return ( + + Which package manager does this project use? + + Drives the install + build steps in the generated workflow. We + {' '} + {detectionNote} + + { + if (value === '__skip__') { + setBuildScriptChoice({ type: 'skip' }) + setStep('preview-workflow-file') + return + } + if (value === '__custom__') { + setStep('pick-build-script-custom') + return + } + setBuildScriptChoice({ type: 'npm-script', name: value }) + setStep('preview-workflow-file') + }} + /> + + )} + + {step === 'pick-build-script-custom' && ( + + Custom build command + + Type the exact command you want the workflow to run before + {' '} + capgo build request + {' '} + (e.g. + {' '} + make web + , + {' '} + bash scripts/build.sh + ). + + + { + const cleaned = value.trim() + if (!cleaned) return - } - trackWorkflowEvent('workflow-preview-action', { decision: value === 'write' ? 'write' : 'cancel' }) - setPreviewDiff([]) - setStep(value === 'write' ? 'writing-workflow-file' : 'build-complete') + setBuildScriptChoice({ type: 'custom', command: cleaned }) + setStep('preview-workflow-file') }} /> - ) - })()} - {step === 'preview-workflow-file' && previewDiff.length === 0 && ( - - - - )} - - {/* view-workflow-diff renders as a fullscreen early-return takeover above. */} - - {step === 'writing-workflow-file' && ( - - - - )} - - {step === 'checking-ci-secrets' && ( - - - - )} - - {step === 'confirm-secrets-push' && ( - (() => { - const existingSet = new Set(ciSecretExistingKeys) - const newCount = ciSecretEntries.filter(entry => !existingSet.has(entry.key)).length - const replaceCount = ciSecretEntries.length - newCount + )} + + {step === 'preview-workflow-file' && previewDiff.length > 0 && (() => { + const allEqual = previewDiff.every(l => l.kind === 'eq') + const writeLabel = allEqual + ? '✏️ Write file anyway (re-writes identical content)' + : (previewIsNew ? '✏️ Write file' : '✏️ Replace existing file') + const skipLabel = '❌ Do not write file' + const title = previewIsNew + ? `🆕 Proposed new file — ${previewExistingPath ?? WORKFLOW_PATH}` + : `✏️ Proposed changes — ${previewExistingPath ?? WORKFLOW_PATH}` + const subtitle = previewIsNew + ? 'Nothing exists on disk yet. Every line below is what would be written.' + : 'Proposed diff vs the file on disk. Lines marked - would be removed, lines marked + would be added.' return ( - ⚠ Confirm before pushing secrets - - Repository: - {' '} - {ciSecretRepoLabel} - {' '} - (resolved via `gh repo view`) - - - {`Will push ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'}`} - {replaceCount > 0 ? ` — ${newCount} new, ${replaceCount} REPLACING existing:` : ' — all new:'} - - - ({ - name: entry.key, - status: existingSet.has(entry.key) ? 'REPLACE' : 'NEW', - }))} + + + + What should we do with + {WORKFLOW_PATH} + ? + + { + setStep(value === 'confirm' ? 'uploading-ci-secrets' : 'build-complete') + }} + /> - ) - })() - )} - {step === 'confirm-secrets-push' && ( - <> - - { - if (value === 'view') { - let lines: string[] = [] - try { lines = readFileSync(supportLogPathRef.current, 'utf8').split('\n') } - catch { lines = ['(could not read the logs file)'] } - setSupportLogLines(lines) - setStep('support-log-view') - return + {step === 'support-confirm' && ( + + Email Capgo support + {supportConfirmMessage} + + +) + +// ── p8-create-method-select ─────────────────────────────────────────────────── +// macOS only: hand-create the key at App Store Connect, or let the guided helper +// drive the whole flow in an embedded browser and capture the key automatically. +export interface P8CreateMethodSelectStepProps { + dense?: boolean + onChange: (value: string) => void | Promise +} + +export const P8CreateMethodSelectStep: FC = ({ dense = false, onChange }) => ( + + + How do you want to create the .p8 key? + + {!dense && } +