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..fcdd26d66d
--- /dev/null
+++ b/cli/native/asc-key-helper/Sources/Models/FlowScripts.swift
@@ -0,0 +1,455 @@
+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) => {
+ // A throwing finder must never kill the loop — a dead loop freezes
+ // every overlay at its last viewport spot, which then visibly drifts
+ // away from its target as the page scrolls.
+ try {
+ 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';
+ }
+ } catch (e) { /* keep ticking — one bad finder must not freeze the rest */ }
+ });
+ 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)
+ }
+
+ /// Attach the highlight DIRECTLY to the real target element — a ring drawn on
+ /// the button itself, not a floating overlay. We only ever set INLINE STYLE on
+ /// an existing node (box-shadow + outline; both paint outside the box, so no
+ /// reflow) and never insert or remove child nodes — so React's reconciler is
+ /// untouched. (The "removeChild must be an instance of Node" crash came from
+ /// inserting nodes into a React-owned parent, not from styling an existing
+ /// one.) Because the style lives ON the element, it tracks the element through
+ /// scroll and layout natively — no rAF, no drift. A light interval re-applies
+ /// in case a re-render clears it, and restores the element's style on teardown.
+ /// Use ONLY for static targets (the "+" generate button); re-rendering
+ /// dropdowns still use the overlay, which never touches their nodes.
+ private static func attachHighlightDirect(finder: String, scroll: Bool = false) -> String {
+ """
+ if (window.__p8hlClear) window.__p8hlClear();
+ (function () {
+ const find = () => { \(finder) };
+ // A round, generous halo that FOLLOWS the button's border-radius (so a
+ // circular "+" gets a circular ring, not a tight square). Pure
+ // box-shadow — no rectangular outline — and large enough to read as a
+ // deliberate highlight around a small ~32px button.
+ const RING = '0 0 0 7px #ff3b30, 0 0 0 15px rgba(255,59,48,0.45), 0 0 36px 16px rgba(255,59,48,0.65)';
+ let el = null, scrolled = false;
+ const paint = (n) => {
+ n.style.setProperty('box-shadow', RING, 'important');
+ };
+ const clear = (n) => {
+ n.style.removeProperty('box-shadow');
+ };
+ const tick = () => {
+ let next = null;
+ try { next = find(); } catch (e) {}
+ if (next !== el) { if (el) clear(el); el = next; scrolled = false; }
+ if (el) {
+ paint(el);
+ if (\(scroll ? "true" : "false") && !scrolled && el.scrollIntoView) {
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ scrolled = true;
+ }
+ }
+ };
+ const id = setInterval(tick, 200);
+ tick();
+ window.__p8hlClear = () => { clearInterval(id); if (el) clear(el); window.__p8hlClear = null; };
+ })();
+ """
+ }
+
+ /// 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 = [];
+ """
+
+ /// One-shot diagnostic for the "Open the Generate dialog" highlight, routed to
+ /// the CLI support log. Reports what the generate-button finder matches (tag,
+ /// text, aria, on-screen rect, visibility), nearby candidate "+"/Generate
+ /// buttons with their positions, and whether / carry a CSS
+ /// `transform` — a transform on an ancestor silently re-bases our `position:
+ /// fixed` overlay, which makes it drift on scroll. Returns a JSON string.
+ static let createKeyHighlightProbe = """
+ const out = {};
+ try {
+ const xp = (e) => document.evaluate(e, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
+ out.hasActiveH3 = !!xp('.//h3[starts-with(normalize-space(), "Active")]');
+ const btn = xp('.//h3[starts-with(normalize-space(), "Active")]/following-sibling::button[1]');
+ out.matched = !!btn;
+ if (btn) {
+ const r = btn.getBoundingClientRect();
+ out.tag = btn.tagName;
+ out.text = (btn.textContent || '').trim().slice(0, 30);
+ out.aria = btn.getAttribute('aria-label') || '';
+ out.visible = btn.getClientRects().length > 0;
+ out.rect = Math.round(r.left) + ',' + Math.round(r.top) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height);
+ }
+ out.candidates = [...document.querySelectorAll('button')].filter(b => {
+ const t = (b.textContent || '').trim();
+ const a = (b.getAttribute('aria-label') || '').toLowerCase();
+ return t === '+' || a.includes('generate') || a.includes('add') || a.includes('create new');
+ }).slice(0, 6).map(b => {
+ const r = b.getBoundingClientRect();
+ return (b.getAttribute('aria-label') || (b.textContent || '').trim().slice(0, 16) || '?')
+ + '@' + Math.round(r.left) + ',' + Math.round(r.top) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height);
+ });
+ out.bodyTransform = getComputedStyle(document.body).transform;
+ out.htmlTransform = getComputedStyle(document.documentElement).transform;
+ out.scrollY = Math.round(window.scrollY);
+ } catch (e) { out.error = String(e); }
+ return JSON.stringify(out);
+ """
+
+ static func highlightScript(for step: FlowStep) -> String? {
+ switch step {
+ case .createKey:
+ // The "+" is a static button, so we attach the ring DIRECTLY to it
+ // (no overlay, no scroll drift). Tear down any leftover overlay first.
+ """
+ \(awaitNoProgressBar)
+ \(removeOverlay)
+ \(attachHighlightDirect(finder: "\(findGenerateButton) return generateButton;", scroll: true))
+ """
+ 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:
+ // createKey attaches its ring directly to the button — clear that,
+ // and any overlay too (defensive against a mixed transition).
+ """
+ if (window.__p8hlClear) window.__p8hlClear();
+ \(removeOverlay)
+ """
+ case .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..4b6a3178b1
--- /dev/null
+++ b/cli/native/asc-key-helper/Sources/Models/GuidedFlowModel.swift
@@ -0,0 +1,893 @@
+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))
+ StatsProtocol.debug("team switch attempt", [
+ "team": team.name,
+ "diagnostics": diagnostics ?? "",
+ ])
+ 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."
+ StatsProtocol.warn("automatic team switch did not land — asking the user to switch manually", [
+ "team": team.name,
+ ])
+ }
+
+ /// 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
+ // A stale PERSISTED session can make Apple's silent re-auth fail
+ // and loop. Wipe the saved session, then reload onto a clean login
+ // wall so the user can sign in fresh (and we re-persist that).
+ StatsProtocol.warn("auth failed — clearing persisted web session, retrying clean login")
+ Task {
+ await WebSessionStore.clear(from: webView)
+ 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
+ StatsProtocol.debug("steering to the API keys page from an off-flow ASC page", [
+ "from": urlString,
+ ])
+ 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",
+ ])
+ // Support needs the team + raw roles to advise the user on who to
+ // ask (Account Holder for enablement, an Admin for a key).
+ StatsProtocol.warn("API access denied for this team", [
+ "reason": (!isEnabled) ? "not_enabled" : "insufficient_role",
+ "team": session.currentTeam.name,
+ "roles": session.roles.joined(separator: ", "),
+ ])
+ }
+ 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
+ }
+ if wrongRoleSelected {
+ // The user picked a role other than the recommended one — Capgo
+ // Builder keys want Admin. Surface exactly what they chose.
+ StatsProtocol.warn("non-recommended role selected for the key", [
+ "selected": selectedRoles.joined(separator: ", "),
+ "recommended": FlowScripts.recommendedRole,
+ ])
+ }
+ // 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,
+ ])
+ // Richer breadcrumb for the support log: the analytics event omits the
+ // page/team context that's decisive when reconstructing a stuck run.
+ StatsProtocol.debug("step \(previous) → \(step)", [
+ "from": String(describing: previous),
+ "to": String(describing: step),
+ "url": currentURL,
+ "team": session?.currentTeam.name ?? "nil",
+ "ever_logged_in": everLoggedIn,
+ ])
+ 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)
+ }
+ // Diagnostic: record what the generate-button highlight actually matched
+ // (and the page's transform context) into the support log, so a misplaced
+ // or drifting highlight can be diagnosed from a bundle without a live DOM.
+ if currentStep == .createKey,
+ let probe = (try? await callJavaScript(FlowScripts.createKeyHighlightProbe)) as? String {
+ StatsProtocol.debug("createKey highlight probe", ["probe": probe])
+ }
+ }
+
+ // 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
+ StatsProtocol.warn("issuer_id scrape returned no value (DOM element not found yet)", [
+ "attempt": issuerScrapeFailures,
+ "url": currentURL,
+ ])
+ if issuerScrapeFailures >= 8, !apiAccessWarningDismissed {
+ scrapeTroubleWarning = true
+ // Persistent miss → almost always an Apple DOM change; this is
+ // the single most useful line for support to see in the bundle.
+ StatsProtocol.error("issuer_id scrape persistently failing — Apple DOM may have changed", [
+ "attempts": issuerScrapeFailures,
+ ])
+ }
+ }
+ }
+
+ 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."
+ // Common user mistake (picked the .cer/.mobileprovision, or a
+ // truncated file). Log the path — not the contents — so support
+ // can see what they chose. redactSecrets backstops the line.
+ StatsProtocol.warn("selected file is not a .p8 private key", [
+ "path": fileURL.path,
+ ])
+ 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),
+ ])
+ // The analytics event omits the reason; the support log needs
+ // Apple's actual error text to tell a bad key from a transient
+ // network/clock-skew failure. This is never secret-bearing.
+ StatsProtocol.error("Apple key validation failed", [
+ "detail": error.localizedDescription,
+ "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..c7c494e686
--- /dev/null
+++ b/cli/native/asc-key-helper/Sources/Models/StatsProtocol.swift
@@ -0,0 +1,105 @@
+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":"…"}
+///
+/// {"capgoAscKey":1,"kind":"log","ts":,"runId":"…","level":"warn","message":"…","props":{…}}
+///
+/// Contract:
+/// - `event` lines carry only non-sensitive `props` — NEVER the private key —
+/// and are forwarded to PostHog analytics.
+/// - `log` lines are verbose diagnostics routed to the CLI's internal support
+/// log (the bundle a user emails to support), NOT analytics. They also never
+/// carry 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.
+/// - free-form stderr is still tolerated but is not part of this protocol;
+/// prefer a structured `log` line so it reaches the support bundle.
+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])
+ }
+
+ /// Emit a verbose diagnostic line routed to the CLI's **internal support
+ /// log** (NOT analytics). Use it generously for anything that helps a human
+ /// diagnose a stuck/failed run after the fact — a finder that matched
+ /// nothing, an unexpected navigation, the detail of a validation error.
+ /// `props` must never contain the private key. `level` is one of
+ /// `debug`/`info`/`warn`/`error` (the CLI defaults anything else to `info`).
+ static func log(_ level: String, _ message: String, _ props: [String: Any] = [:]) {
+ writeLine(["kind": "log", "level": level, "message": message, "props": props])
+ }
+
+ static func debug(_ message: String, _ props: [String: Any] = [:]) { log("debug", message, props) }
+ static func info(_ message: String, _ props: [String: Any] = [:]) { log("info", message, props) }
+ static func warn(_ message: String, _ props: [String: Any] = [:]) { log("warn", message, props) }
+ static func error(_ message: String, _ props: [String: Any] = [:]) { log("error", message, 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/Util/WebSessionStore.swift b/cli/native/asc-key-helper/Sources/Util/WebSessionStore.swift
new file mode 100644
index 0000000000..73bc18cd7b
--- /dev/null
+++ b/cli/native/asc-key-helper/Sources/Util/WebSessionStore.swift
@@ -0,0 +1,128 @@
+import Foundation
+import WebKit
+
+/// Persists the embedded browser's Apple sign-in across helper launches by
+/// saving and restoring the session COOKIES ourselves.
+///
+/// Why not `WKWebsiteDataStore(forIdentifier:)`? That persistent store requires a
+/// real app identity/entitlements; in this CLI-spawned, NON-BUNDLED executable
+/// macOS force-terminates the process (SIGKILL) when it's used. Manual cookie
+/// persistence needs no entitlements and works anywhere.
+///
+/// We capture cookies via the NATIVE `WKHTTPCookieStore` — which, unlike
+/// `document.cookie`, returns Apple's HttpOnly auth cookies — and re-inject them
+/// on the next launch. HttpOnly only restricts JavaScript access; a re-injected
+/// cookie is still sent to appstoreconnect.apple.com, so the session is restored.
+/// (An earlier attempt failed because it read cookies via JS and so missed the
+/// HttpOnly session cookies — only "part of" the session survived.)
+///
+/// The saved cookies are Apple session credentials, so the file is written 0600
+/// under ~/.capgo. Set `CAPGO_ASC_KEY_FRESH_SESSION` to skip persistence for a
+/// run (e.g. to sign in as a different Apple ID).
+@MainActor
+enum WebSessionStore {
+ private static let dir = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent(".capgo/asc-key-helper", isDirectory: true)
+ private static let cookieFile = dir.appendingPathComponent("asc-cookies.json")
+
+ /// False when the user asked for a clean, throwaway session this run.
+ static let isEnabled: Bool = ProcessInfo.processInfo.environment["CAPGO_ASC_KEY_FRESH_SESSION"] == nil
+
+ /// The web view's store stays non-persistent — WE own persistence (and this
+ /// avoids the forIdentifier entitlement SIGKILL on a non-bundled binary).
+ static func makeDataStore() -> WKWebsiteDataStore { .nonPersistent() }
+
+ // MARK: - On-disk shape
+
+ private struct StoredCookie: Codable {
+ let name: String
+ let value: String
+ let domain: String
+ let path: String
+ let secure: Bool
+ /// Seconds since 1970; nil = a session cookie (no explicit expiry).
+ let expires: Double?
+ }
+
+ private static func appleOnly(_ cookies: [HTTPCookie]) -> [HTTPCookie] {
+ cookies.filter { $0.domain.contains("apple.com") }
+ }
+
+ // MARK: - Async cookie-store bridges (completion-handler APIs always exist)
+
+ private static func getAll(_ store: WKHTTPCookieStore) async -> [HTTPCookie] {
+ await withCheckedContinuation { cont in
+ store.getAllCookies { cont.resume(returning: $0) }
+ }
+ }
+
+ private static func set(_ cookie: HTTPCookie, into store: WKHTTPCookieStore) async {
+ await withCheckedContinuation { cont in
+ store.setCookie(cookie) { cont.resume() }
+ }
+ }
+
+ private static func remove(_ cookie: HTTPCookie, from store: WKHTTPCookieStore) async {
+ await withCheckedContinuation { cont in
+ store.delete(cookie) { cont.resume() }
+ }
+ }
+
+ // MARK: - Public API
+
+ /// Re-inject the saved Apple cookies into the web view BEFORE its first load,
+ /// so a previously signed-in user lands already authenticated.
+ static func restore(into webView: WKWebView) async {
+ guard isEnabled,
+ let data = try? Data(contentsOf: cookieFile),
+ let stored = try? JSONDecoder().decode([StoredCookie].self, from: data) else {
+ return
+ }
+ let store = webView.configuration.websiteDataStore.httpCookieStore
+ for sc in stored {
+ var props: [HTTPCookiePropertyKey: Any] = [
+ .name: sc.name,
+ .value: sc.value,
+ .domain: sc.domain,
+ .path: sc.path,
+ ]
+ if sc.secure { props[.secure] = "TRUE" }
+ if let exp = sc.expires { props[.expires] = Date(timeIntervalSince1970: exp) }
+ if let cookie = HTTPCookie(properties: props) {
+ await set(cookie, into: store)
+ }
+ }
+ }
+
+ /// Save the current Apple cookies (incl. the HttpOnly session cookies) to
+ /// disk. Best-effort; called after page loads so the session is captured
+ /// during the flow, before the helper exits.
+ static func persist(from webView: WKWebView) async {
+ guard isEnabled else { return }
+ let store = webView.configuration.websiteDataStore.httpCookieStore
+ let stored = appleOnly(await getAll(store)).map { c in
+ StoredCookie(
+ name: c.name,
+ value: c.value,
+ domain: c.domain,
+ path: c.path,
+ secure: c.isSecure,
+ expires: c.expiresDate?.timeIntervalSince1970
+ )
+ }
+ guard !stored.isEmpty, let data = try? JSONEncoder().encode(stored) else { return }
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ try? data.write(to: cookieFile, options: [.atomic])
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: cookieFile.path)
+ }
+
+ /// Wipe the saved session (file + the live Apple cookies) — used when a stale
+ /// login keeps failing, so the reload lands on a clean Apple sign-in wall.
+ static func clear(from webView: WKWebView?) async {
+ try? FileManager.default.removeItem(at: cookieFile)
+ guard let store = webView?.configuration.websiteDataStore.httpCookieStore else { return }
+ for cookie in appleOnly(await getAll(store)) {
+ await remove(cookie, from: store)
+ }
+ }
+}
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..c619a682c7
--- /dev/null
+++ b/cli/native/asc-key-helper/Sources/Web/WebViewContainer.swift
@@ -0,0 +1,176 @@
+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()
+ // The store itself is non-persistent — WE persist the Apple sign-in by
+ // saving/restoring the session cookies ourselves (see WebSessionStore).
+ // A WKWebsiteDataStore(forIdentifier:) needs app entitlements this
+ // non-bundled binary lacks (macOS SIGKILLs it), so we capture cookies via
+ // the native cookie store instead and re-inject them next launch.
+ configuration.websiteDataStore = WebSessionStore.makeDataStore()
+ 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)
+ // Restore any saved Apple session cookies BEFORE the first load, then load
+ // the keys page: a returning user lands already signed in; a new user gets
+ // Apple's login wall with this page as the post-login redirect target.
+ Task { @MainActor in
+ await WebSessionStore.restore(into: webView)
+ 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?
+ /// Throttles cookie persistence so we don't write on every sub-frame load.
+ private var lastCookieSave = Date.distantPast
+
+ 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()
+ // Capture the session cookies as the user progresses (throttled) so a
+ // successful sign-in is persisted to disk before the helper ever exits.
+ if Date().timeIntervalSince(lastCookieSave) > 2 {
+ lastCookieSave = Date()
+ Task { await WebSessionStore.persist(from: webView) }
+ }
+ }
+}
+
+// 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/package.json b/cli/package.json
index 918cfb23f4..1dea81d95d 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",
@@ -114,7 +115,7 @@
"test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs",
"test:self-update": "bun test/test-self-update.mjs",
"test:update-prompt": "bun test/test-update-prompt.mjs",
- "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt",
+ "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
"test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs",
diff --git a/cli/scripts/build-asc-key-helper.sh b/cli/scripts/build-asc-key-helper.sh
new file mode 100755
index 0000000000..035a517e02
--- /dev/null
+++ b/cli/scripts/build-asc-key-helper.sh
@@ -0,0 +1,75 @@
+#!/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 [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 # builds the vendored package
+# 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
+
+# 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 [[ ! -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
+
+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..24cf2ba3b1
--- /dev/null
+++ b/cli/src/build/onboarding/asc-key/PROTOCOL.md
@@ -0,0 +1,117 @@
+# 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, append verbose diagnostics to its
+**internal support log**, and receive the final credentials. This document is the
+contract between the Swift helper (`StatsProtocol.swift`) and the CLI
+(`protocol.ts` / `helper.ts`).
+
+Three line kinds travel the same stdout channel:
+
+- **`event`** → forwarded to **PostHog** (structured, low-volume analytics).
+- **`log`** → appended to the **internal support log** (verbose diagnostics — the
+ bundle a user emails to support when a run goes wrong). Never analytics.
+- **`result`** → the terminal line carrying the credentials (or a failure).
+
+## 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. Free-form stderr is still tolerated, but is NOT
+part of this protocol — prefer a `log` line so the diagnostic reaches the bundle.
+
+```jsonc
+{"capgoAscKey":1,"kind":"event","ts":12,"runId":"","name":"step_changed","props":{ }}
+{"capgoAscKey":1,"kind":"log","ts":420,"runId":"","level":"warn","message":"issuer_id scrape returned no value","props":{"attempt":3}}
+{"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"`, `"log"`, 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, log | Non-sensitive properties (never the private key). |
+| `level` | log | `debug` \| `info` \| `warn` \| `error` (else `info`).|
+| `message` | log | Human-readable diagnostic (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`).
+- **`log` lines are appended to the CLI's internal support log**, never to
+ analytics. The CLI passes the helper's own line through with minimal shaping —
+ `asc-helper : ` — and `appendInternalLog` supplies
+ the timestamp and runs `redactSecrets` over it (the single redaction authority;
+ the CLI does not add a bespoke format or a second secret guard). It also writes
+ a one-line per-run summary (outcome + event/log counts) so a bundle always
+ shows the helper ran.
+- 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 event 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.
+
+## Diagnostic logs
+
+`log` lines are verbose, free-form diagnostics for the support bundle. Unlike
+events they are not a fixed taxonomy — the helper emits them generously wherever
+context would help a human reconstruct a stuck or failed run after the fact.
+Current emit points (see `GuidedFlowModel.swift`):
+
+| `level` | `message` (abridged) | Emitted when |
+| ------- | ----------------------------------------------------- | --------------------------------------------- |
+| `debug` | `step → ` (+ url, team, ever_logged_in) | Every guided-step transition. |
+| `debug` | `team switch attempt` (+ diagnostics) | The account-menu team switch is driven. |
+| `debug` | `steering to the API keys page…` | The user wandered to an off-flow ASC page. |
+| `warn` | `issuer_id scrape returned no value…` | A DOM finder didn't match (per attempt). |
+| `warn` | `non-recommended role selected for the key` | The user chose a role other than Admin. |
+| `warn` | `API access denied for this team` (+ team, roles) | The team can't create a key. |
+| `warn` | `automatic team switch did not land…` | The auto-switch timed out → manual fallback. |
+| `warn` | `selected file is not a .p8 private key` (+ path) | The user picked the wrong file. |
+| `error` | `issuer_id scrape persistently failing…` | The Issuer ID finder missed ≥8 times. |
+| `error` | `Apple key validation failed` (+ detail) | Apple rejected the new key. |
+
+New `log` lines can be added freely — the CLI appends any `log` line generically.
+Adding emit points never requires a version bump.
+
+## 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..d08190134a
--- /dev/null
+++ b/cli/src/build/onboarding/asc-key/command.ts
@@ -0,0 +1,125 @@
+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 { appendInternalLog, getInternalLogPath, startInternalLog } from '../../../support/internal-log'
+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)
+ }
+
+ // Start a support log for this run (no-op if it can't be created). The helper
+ // streams diagnostic `log` lines into it, so a user who hits trouble can send
+ // the bundle to support. The onboarding (`build init`) path starts its own.
+ if (!getInternalLogPath()) {
+ const logPath = startInternalLog(options.appId)
+ if (logPath)
+ appendInternalLog(`apple-key: guided helper starting for app ${options.appId ?? '(none)'}`)
+ }
+
+ 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..207d23e0eb
--- /dev/null
+++ b/cli/src/build/onboarding/asc-key/helper.ts
@@ -0,0 +1,279 @@
+import type { Buffer } from 'node:buffer'
+import type { AscCredentials, AscEventLine, AscLogLine, AscProtocolLine, AscResultLine } from './protocol'
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join } from 'node:path'
+import process, { env, platform } from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { trackEvent } from '../../../analytics/track'
+import { appendInternalLog } from '../../../support/internal-log'
+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'
+}
+
+/** 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 {
+ 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
+ for (const candidate of localBuildCandidates()) {
+ if (existsSync(candidate))
+ return candidate
+ }
+ return null
+}
+
+export interface AscHelperSuccess {
+ ok: true
+ credentials: AscCredentials
+ runId: string
+ /** Number of stats events the helper emitted during the run. */
+ eventCount: number
+ /** Number of diagnostic `log` lines routed to the internal support log. */
+ logCount: number
+}
+
+export interface AscHelperFailure {
+ ok: false
+ errorCode: string
+ message: string
+ runId: string
+ /** Number of diagnostic `log` lines routed to the internal support log. */
+ logCount: number
+}
+
+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
+ /** Optional observer for every diagnostic log line (UI / tests). */
+ onLog?: (line: AscLogLine) => void
+ /** Forward events to PostHog via trackEvent. Defaults to true. */
+ forwardToAnalytics?: boolean
+ /**
+ * Append diagnostic `log` lines (and a per-run summary) to the CLI's internal
+ * support log via {@link appendInternalLog}. Defaults to true. The append is
+ * best-effort and no-ops when no internal log has been started for this run.
+ */
+ forwardToInternalLog?: boolean
+ /**
+ * Abort signal. When it fires — e.g. the onboarding TUI unmounts because the
+ * user quit — the helper child is terminated (SIGTERM, then SIGKILL) so its
+ * stdio pipes stop keeping the CLI process alive. Without this the CLI hangs
+ * after exit while the helper window is still open.
+ */
+ signal?: AbortSignal
+}
+
+/**
+ * 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: '',
+ logCount: 0,
+ }
+ }
+
+ const forward = options.forwardToAnalytics !== false
+ const toInternalLog = options.forwardToInternalLog !== 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 logCount = 0
+ let runId = ''
+
+ // Tear the helper down when the caller aborts (the onboarding TUI unmounts
+ // on quit) or the CLI process exits. The child's stdio pipes otherwise keep
+ // Node's event loop alive, so the CLI hangs after printing its exit message.
+ let killTimer: ReturnType | undefined
+ let wasAborted = false
+ const killChild = (sig: NodeJS.Signals): void => {
+ try {
+ child.kill(sig)
+ }
+ catch {
+ // Already exited — nothing to kill.
+ }
+ }
+ const onAbort = (): void => {
+ wasAborted = true
+ killChild('SIGTERM')
+ // Escalate if the GUI helper doesn't exit promptly on SIGTERM.
+ killTimer = setTimeout(() => killChild('SIGKILL'), 2000)
+ killTimer.unref?.()
+ }
+ const onProcExit = (): void => killChild('SIGKILL')
+ const detach = (): void => {
+ if (killTimer)
+ clearTimeout(killTimer)
+ options.signal?.removeEventListener('abort', onAbort)
+ process.removeListener('exit', onProcExit)
+ }
+ if (options.signal?.aborted)
+ onAbort()
+ else
+ options.signal?.addEventListener('abort', onAbort, { once: true })
+ process.once('exit', onProcExit)
+
+ 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 if (line.kind === 'log') {
+ // Verbose diagnostics → the internal support log, NOT analytics. We pass
+ // the helper's own line through with minimal shaping (source tag + level
+ // + message + structured context). appendInternalLog supplies the
+ // timestamp and runs redactSecrets as the secret backstop — the CLI does
+ // not render a bespoke format of its own.
+ logCount += 1
+ options.onLog?.(line)
+ if (toInternalLog) {
+ const ctx = Object.keys(line.props).length ? ` ${JSON.stringify(line.props)}` : ''
+ appendInternalLog(`asc-helper ${line.level}: ${line.message}${ctx}`)
+ }
+ }
+ 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')
+ })
+ // Breadcrumb the run's outcome into the support log so a bundle always shows
+ // the helper ran (and how it ended), even when it emitted no diagnostics.
+ const breadcrumb = (text: string): void => {
+ if (toInternalLog)
+ appendInternalLog(text)
+ }
+
+ child.once('error', (err) => {
+ detach()
+ const message = err instanceof Error ? err.message : String(err)
+ breadcrumb(`[asc-helper] run ${runId || '(no id)'} failed to spawn (SPAWN_FAILED): ${message}`)
+ resolve({ ok: false, errorCode: 'SPAWN_FAILED', message, runId, logCount })
+ })
+ child.once('close', (code, signal) => {
+ detach()
+ for (const line of parser.flush())
+ handleLine(line)
+
+ if (result?.ok && result.keyId && result.issuerId && result.privateKey) {
+ breadcrumb(`[asc-helper] run ${runId || '(no id)'} succeeded — ${eventCount} events, ${logCount} logs`)
+ resolve({
+ ok: true,
+ credentials: { keyId: result.keyId, issuerId: result.issuerId, privateKey: result.privateKey },
+ runId,
+ eventCount,
+ logCount,
+ })
+ return
+ }
+
+ // Distinguish OUR intentional teardown (abort on quit → SIGTERM/SIGKILL)
+ // from the helper dying on its own. A `signal` we DIDN'T send means the
+ // helper crashed (e.g. SIGSEGV/SIGABRT/SIGILL) — surface the signal name
+ // so a support bundle shows it instead of a bare "code null".
+ const errorCode = result?.errorCode
+ ?? (wasAborted ? 'USER_CANCELLED' : code === 1 ? 'USER_CANCELLED' : signal ? 'HELPER_CRASHED' : 'NO_RESULT')
+ const message = result?.message
+ ?? (wasAborted
+ ? 'Helper was stopped because the CLI exited or was cancelled.'
+ : code === 1
+ ? 'Helper was cancelled before delivering a key.'
+ : signal
+ ? `Helper crashed (killed by ${signal}) without a result line.${stderr.trim() ? ` Stderr: ${stderr.trim()}` : ''}`
+ : `Helper exited (code ${code}) without a result line.${stderr.trim() ? ` Stderr: ${stderr.trim()}` : ''}`)
+ breadcrumb(`[asc-helper] run ${runId || '(no id)'} ended without a key (${errorCode}, code=${code}, signal=${signal ?? 'none'}): ${message} — ${eventCount} events, ${logCount} logs`)
+ resolve({ ok: false, errorCode, message, runId, logCount })
+ })
+ })
+}
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..8193cb994f
--- /dev/null
+++ b/cli/src/build/onboarding/asc-key/protocol.ts
@@ -0,0 +1,260 @@
+// 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
+}
+
+/** Severity levels a helper diagnostic `log` line may carry. */
+export const ASC_LOG_LEVELS = ['debug', 'info', 'warn', 'error'] as const
+export type AscLogLevel = (typeof ASC_LOG_LEVELS)[number]
+
+/**
+ * A verbose diagnostic line. Unlike an `event` (which feeds PostHog analytics),
+ * a `log` line is routed into the CLI's **internal support log** — the bundle a
+ * user emails to support when a run goes wrong. Use it generously for anything
+ * that helps a human diagnose a stuck/failed run after the fact: a finder that
+ * matched nothing, an unexpected navigation, the detail of a validation error.
+ * It is NOT analytics and, like an event, NEVER carries the private key.
+ */
+export interface AscLogLine {
+ capgoAscKey: number
+ kind: 'log'
+ /** Milliseconds since the helper started. */
+ ts: number
+ /** Correlates every line of a single helper run. */
+ runId: string
+ /** Severity, defaulting to `info` when the helper omits/garbles it. */
+ level: AscLogLevel
+ /** Human-readable diagnostic message. */
+ message: string
+ /** Optional structured context. 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 | AscLogLine | AscResultLine
+
+/** Coerce an arbitrary `level` value to a known {@link AscLogLevel}. */
+function normalizeLogLevel(value: unknown): AscLogLevel {
+ return (typeof value === 'string' && (ASC_LOG_LEVELS as readonly string[]).includes(value))
+ ? value as AscLogLevel
+ : 'info'
+}
+
+/**
+ * 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 === 'log') {
+ if (typeof obj.message !== 'string')
+ return null
+ return {
+ capgoAscKey: ASC_PROTOCOL_VERSION,
+ kind: 'log',
+ ts: typeof obj.ts === 'number' ? obj.ts : 0,
+ runId: typeof obj.runId === 'string' ? obj.runId : '',
+ level: normalizeLogLevel(obj.level),
+ message: obj.message,
+ 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),
+ }
+}
+
+// NOTE: `log` lines are routed to the internal support log by the CLI consumer
+// (helper.ts) with minimal shaping — the helper's own level/message/props pass
+// through, and `appendInternalLog` supplies the timestamp and runs
+// `redactSecrets`. The CLI deliberately does NOT render a bespoke display format
+// here; secret coverage lives in one place (redactSecrets), not a second guard.
diff --git a/cli/src/build/onboarding/progress.ts b/cli/src/build/onboarding/progress.ts
index a0bbb2347f..9cba2e67db 100644
--- a/cli/src/build/onboarding/progress.ts
+++ b/cli/src/build/onboarding/progress.ts
@@ -137,6 +137,13 @@ export function getResumeStep(progress: OnboardingProgress | null): OnboardingSt
return 'input-issuer-id'
if (progress.p8Path)
return 'input-key-id'
+ // No .p8 inputs yet. A user who chose the guided macOS helper
+ // (`p8CreateMethod === 'automated'`) deliberately opted out of the manual
+ // .p8 picker — resume them back on the helper (`asc-key-generating`
+ // re-launches the guided window), not the manual instructions. Manual
+ // choosers (and legacy/undefined) fall through to the .p8 instructions.
+ if (progress.p8CreateMethod === 'automated')
+ return 'asc-key-generating'
return 'api-key-instructions'
}
if (!completedSteps.certificateCreated) {
diff --git a/cli/src/build/onboarding/types.ts b/cli/src/build/onboarding/types.ts
index 7a715e5310..78f2dacfe6 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'
@@ -197,6 +201,19 @@ export interface OnboardingProgress {
* resume defaults to `create-new` for backward compatibility.
*/
setupMethod?: 'create-new' | 'import-existing'
+ /**
+ * Records how the user chose to obtain the .p8 in the create-new flow's
+ * source fork (`p8-source-select` → `p8-create-method-select`):
+ * - `automated` — the guided macOS helper creates + captures the key.
+ * - `manual` — the user has a .p8, or creates one by hand at App Store
+ * Connect, and enters it via `api-key-instructions`.
+ *
+ * Persisted so a quit-and-resume lands the user back where they chose to be:
+ * an `automated` user resumes on the helper (`asc-key-generating`), NOT the
+ * manual .p8 picker. Absent on legacy files and on the import flow.
+ * Only meaningful when `setupMethod === 'create-new'`.
+ */
+ p8CreateMethod?: 'automated' | 'manual'
/**
* Records the distribution mode picked at `import-distribution-mode`.
*
@@ -264,6 +281,9 @@ export const STEP_PROGRESS: Record = {
'import-export-warning': 70,
'import-exporting': 75,
// Create-new sub-flow
+ 'p8-source-select': 4,
+ 'p8-create-method-select': 4,
+ 'asc-key-generating': 22,
'api-key-instructions': 5,
'p8-method-select': 8,
'input-p8-path': 10,
@@ -350,6 +370,9 @@ export function getPhaseLabel(step: OnboardingStep): string {
case 'import-export-warning':
case 'import-exporting':
return 'Step 4 of 4 · Export from Keychain'
+ case 'p8-source-select':
+ case 'p8-create-method-select':
+ case 'asc-key-generating':
case 'api-key-instructions':
case 'p8-method-select':
case 'input-p8-path':
diff --git a/cli/src/build/onboarding/ui/app.tsx b/cli/src/build/onboarding/ui/app.tsx
index 9d693d30a0..70271fe64a 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
}
@@ -528,6 +534,9 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres
const p8PathRef = useRef(p8Path)
const keyIdRef = useRef(keyId)
const issuerIdRef = useRef(issuerId)
+ // Lets the asc-key-generating effect's cleanup kill the guided helper child
+ // when the user quits the TUI (otherwise the helper's pipes hang the CLI).
+ const ascHelperAbortRef = useRef(null)
// Wrapper that keeps both state and ref in sync
const setP8Content = useCallback((val: string) => {
@@ -976,7 +985,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)
@@ -1024,6 +1033,18 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres
await saveProgress(appId, existing)
}, [appId])
+ // Persist which .p8 source the user picked in the create-new fork so a
+ // quit-and-resume routes back to the same path (see getResumeStep): an
+ // `automated` user resumes on the guided helper, a `manual` user on the .p8
+ // instructions. Best-effort — a missing progress file just means a fresh run.
+ const persistP8CreateMethod = useCallback(async (method: 'automated' | 'manual') => {
+ const existing = await loadProgress(appId)
+ if (!existing)
+ return
+ existing.p8CreateMethod = method
+ await saveProgress(appId, existing)
+ }, [appId])
+
/**
* Reset everything for a fresh-start onboarding pass. Called from:
* • the ErrorStep restart handler (existing user-facing "Restart" option),
@@ -1059,7 +1080,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 +1697,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 +1907,49 @@ 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. The
+ // abort controller (aborted in this effect's cleanup) terminates the
+ // helper window if the user quits the TUI, so the CLI doesn't hang.
+ const abort = new AbortController()
+ ascHelperAbortRef.current = abort
+ const outcome = await runAscKeyHelper({ apikey, signal: abort.signal })
+ 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
+ // The helper also saved the .p8 to the fastlane/ASC conventional path.
+ const helperP8Path = join(homedir(), '.appstoreconnect', 'private_keys', `AuthKey_${credentials.keyId}.p8`)
+ setP8Content(credentials.privateKey) // wrapper also updates p8ContentRef
+ setKeyId(credentials.keyId)
+ setIssuerId(credentials.issuerId)
+ setP8Path(helperP8Path)
+ // Persist all three (incl. p8Path) so a resume AFTER the key was
+ // captured lands on verifying-key — not back on the helper re-run.
+ await savePartialProgress({ keyId: credentials.keyId, issuerId: credentials.issuerId, p8Path: helperP8Path })
+ 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 +2384,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 })
@@ -3022,6 +3086,16 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres
}
}, [step])
+ // Kill the guided helper child ONLY when the whole onboarding unmounts (the
+ // user quit / Ctrl+C) — NOT on every step transition. Aborting per-step could
+ // tear down a still-running helper if the step ever churned; an unmount-scoped
+ // cleanup fires once, at real exit, and otherwise leaves a live run alone.
+ useEffect(() => {
+ return () => {
+ ascHelperAbortRef.current?.abort()
+ }
+ }, [])
+
// Spinner-frame ticker for the in-flight profile prefetch cells. Runs only
// while at least one entry in profilePrefetch is `pending`; the cleanup
// function clears the interval the instant the last row resolves, AND on
@@ -3153,7 +3227,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 +3326,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}`}
-
- {
+ {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 +3405,603 @@ 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)
-
- if (value === 'import') {
- setImportMode(true)
- setStep('import-scanning')
- }
- else {
- setImportMode(false)
- setStep('api-key-instructions')
- }
- }}
- />
- )}
+ const existing = await loadProgress(appId) || {
+ platform: 'ios' as const,
+ appId,
+ startedAt: new Date().toISOString(),
+ completedSteps: {},
+ }
+ existing.setupMethod = value === 'import' ? 'import-existing' : 'create-new'
+ // Re-entering the create fork: clear any prior .p8 source choice so
+ // quitting before re-choosing doesn't resume onto a path the user
+ // has since left (it gets re-set when they pick in the fork).
+ if (value !== 'import')
+ existing.p8CreateMethod = undefined
+ await saveProgress(appId, existing)
- {/* Import: scanning */}
- {step === 'import-scanning' && }
+ if (value === 'import') {
+ setImportMode(true)
+ setStep('import-scanning')
+ }
+ else {
+ setImportMode(false)
+ // Only ask "do you already have a .p8?" when answering it
+ // actually changes the path — i.e. when the guided macOS helper
+ // is available to offer. Otherwise both answers funnel to the
+ // same manual instructions, so skip straight there (the pre-fork
+ // behaviour on every non-automatable host: non-macOS, or macOS
+ // without the helper binary).
+ if (isMacOS() && resolveHelperBinary() !== null)
+ setStep('p8-source-select')
+ else
+ 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
+ {/* Do you already have a .p8 file? */}
+ {step === 'p8-source-select' && (
+ {
+ if (value === 'have') {
+ await persistP8CreateMethod('manual')
+ setStep('api-key-instructions')
+ }
+ else if (isMacOS() && resolveHelperBinary() !== null) {
+ // No key yet, and we can drive the guided helper. The method
+ // (automated vs manual) is persisted at p8-create-method-select.
+ setStep('p8-create-method-select')
+ }
+ else {
+ // No automation available — fall back to the manual instructions.
+ await persistP8CreateMethod('manual')
+ setStep('api-key-instructions')
+ }
+ }}
+ />
+ )}
+
+ {/* How to create the .p8: guided helper vs by hand (macOS only) */}
+ {step === 'p8-create-method-select' && (
+ {
+ if (value === 'automated') {
+ // Remember the guided path so a quit-and-resume re-launches the
+ // helper instead of dropping the user on the manual .p8 picker.
+ await persistP8CreateMethod('automated')
+ setStep('asc-key-generating')
+ }
+ else {
+ await persistP8CreateMethod('manual')
+ setStep('api-key-instructions')
+ }
+ }}
+ />
+ )}
+
+ {/* Guided helper is running in its own window */}
+ {step === 'asc-key-generating' && }
+
+ {/* Import: scanning */}
+ {step === 'import-scanning' && }
+
+ {/* 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.
-
- )
- }
+ {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.
+
+ )
+ }
- const releaseId = verifyReleaseBundleId
+ const releaseId = verifyReleaseBundleId
- // 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)
- }
+ // 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)
+ }
- // 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' })
- }
+ // 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' })
+ }
- // 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()
- }
+ // 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()
+ }
- // 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)
- }
- }
+ // 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)
+ }
+ }
- // 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)
- }
+ // 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)
+ }
- const cancelGate = (path: GatePath) => {
- trackVerifyEvent('iOS App Verify Cancelled', '🚫', { attempt: verifyAttempt, path })
- addLog('Exiting onboarding.', 'yellow')
- exitOnboarding()
- }
+ const cancelGate = (path: GatePath) => {
+ trackVerifyEvent('iOS App Verify Cancelled', '🚫', { attempt: verifyAttempt, path })
+ addLog('Exiting onboarding.', 'yellow')
+ exitOnboarding()
+ }
- // 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)
- }
+ // 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)
+ }
- // 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')
- }}
- />
-
- )
- }
+ // 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}`}
+ // ── 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.
+
+
+ {
+ 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.
+
+
+ 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}).`}
+
- {`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.'}
+
+ {`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?`}
+
+
+ ({
+ 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
+ }
+ setVerifyChosenApp(chosen)
+ setVerifyPath('fix-build-id')
+ }}
+ />
-
- {
- 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.'}
-
-
- 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?`}
-
-
- ({
- 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 +4009,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 +4019,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:
+ ({
+ label: `[${i + 1}] ${m.identity.name}`,
+ value: m.identity.sha1,
+ })),
+ { label: '↩️ Cancel and use Create new instead', value: '__cancel__' },
+ ]}
+ onChange={onPick}
+ />
+
+ )
+ })()}
+
+ {/* Import: pick profile */}
+ {step === 'import-pick-profile' && chosenIdentity && (() => {
+ const allMatchedProfiles = importMatches.find(m => m.identity.sha1 === chosenIdentity.sha1)?.profiles || []
+ // Filter to profiles that are actually usable for THIS app + THIS
+ // distribution mode. Without this filter, a user with a cert reused
+ // across multiple apps (or with both app_store and ad_hoc profiles
+ // linked to one cert) could pick a profile whose bundleId !== iosBundleId
+ // or whose profileType !== importDistribution. `doSaveCredentials`
+ // would then persist a mismatched provisioning_map / distribution
+ // pair, producing unusable signing credentials.
+ const matchedProfiles = filterProfilesForApp(allMatchedProfiles, iosBundleId, importDistribution)
+ const droppedCount = allMatchedProfiles.length - matchedProfiles.length
+ 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__' },
+ ...matchedProfiles.map(p => ({
+ label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
+ // Key by UUID, NOT path. Disk-discovered profiles have a
+ // unique path, but Apple-fetched profiles (from the D
+ // no-match-recovery path) are synthesized with path=''.
+ // UUID is unique for both kinds: disk profiles use the
+ // mobileprovision UUID, synthesized ones use Apple's
+ // profile resource ID.
+ value: p.uuid,
+ })),
+ { label: '↩️ Back to identity selection', value: '__back__' },
]}
- onChange={onPick}
+ onChange={(value) => {
+ if (value === '__back__') {
+ setStep('import-pick-identity')
+ return
+ }
+ const profile = matchedProfiles.find(p => p.uuid === value)
+ if (!profile)
+ return
+ // Defense in depth: verify bundleId + profileType match before
+ // committing. The filter above should make this unreachable,
+ // but if the filter regresses, we'd rather hard-fail than
+ // silently save bad creds. Wildcard bundle ids
+ // (`com.example.*`, bare `*`) are accepted via bundleIdMatches
+ // so this guard stays in sync with the picker's filter — a
+ // strict equality here would over-reject wildcards the filter
+ // intentionally accepted upstream.
+ if (!bundleIdMatches(profile.bundleId, iosBundleId)
+ || (importDistribution && profile.profileType !== importDistribution)) {
+ handleError(
+ new Error(
+ `Profile "${profile.name}" doesn't match this app: `
+ + `bundle ${profile.bundleId} (expected ${iosBundleId}), `
+ + `type ${profile.profileType} (expected ${importDistribution ?? 'any'}).`,
+ ),
+ 'import-pick-profile',
+ )
+ return
+ }
+ // Belt-and-suspenders: the upstream matchIdentitiesToProfiles
+ // filter and Apple-fetched profile synthesizing should both
+ // guarantee `profile.certificateSha1s` contains
+ // `chosenIdentity.sha1`. But the file-picker recovery path
+ // imports a .mobileprovision the user might have hand-created
+ // in the portal — if they ticked the wrong cert in the cert
+ // list there, we'd otherwise save credentials that the build
+ // server can't actually sign with (private key from
+ // chosenIdentity but profile only trusts a different cert).
+ // Catch that here with a clear error rather than discovering
+ // it during a build hours later.
+ if (chosenIdentity && !profile.certificateSha1s.includes(chosenIdentity.sha1)) {
+ const shownSha1s = profile.certificateSha1s.map(s => `${s.slice(0, 8)}…`).join(', ') || '(none listed)'
+ handleError(
+ new Error(
+ `Profile "${profile.name}" doesn't trust your chosen certificate "${chosenIdentity.name}". `
+ + `The profile's allowed-certs list contains ${profile.certificateSha1s.length} entr${profile.certificateSha1s.length === 1 ? 'y' : 'ies'} (SHA1: ${shownSha1s}); your cert's SHA1 starts with ${chosenIdentity.sha1.slice(0, 8)}…. `
+ + `Either pick a different profile, or re-create this profile in the Apple Developer Portal and tick the right cert.`,
+ ),
+ 'import-pick-profile',
+ )
+ return
+ }
+ setChosenProfile(profile)
+ addLog(`✔ Profile · ${profile.name}`)
+ setStep('import-export-warning')
+ }}
/>
)
- }
-
- 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:
- {
+ const hasAscKey = !!(p8ContentRef.current || p8PathRef.current)
+ // D2 (create-profile-only) currently only knows how to create
+ // IOS_APP_STORE profiles via apple-api.ts createProfile, which
+ // hardcodes that profileType. For ad_hoc we'd need a separate
+ // create path that calls Apple with IOS_APP_ADHOC. Until that
+ // exists, hide the "Create" option for ad_hoc users so they
+ // can't end up with an app_store profile saved under
+ // CAPGO_IOS_DISTRIBUTION='ad_hoc'. Browser + Fetch still work.
+ const canCreateProfile = importDistribution !== 'ad_hoc'
+ return (
+ ({
- label: `[${i + 1}] ${m.identity.name}`,
- value: m.identity.sha1,
- })),
- { label: '↩️ Cancel and use Create new instead', value: '__cancel__' },
- ]}
- onChange={onPick}
- />
-
- )
- })()}
-
- {/* Import: pick profile */}
- {step === 'import-pick-profile' && chosenIdentity && (() => {
- const allMatchedProfiles = importMatches.find(m => m.identity.sha1 === chosenIdentity.sha1)?.profiles || []
- // Filter to profiles that are actually usable for THIS app + THIS
- // distribution mode. Without this filter, a user with a cert reused
- // across multiple apps (or with both app_store and ad_hoc profiles
- // linked to one cert) could pick a profile whose bundleId !== iosBundleId
- // or whose profileType !== importDistribution. `doSaveCredentials`
- // would then persist a mismatched provisioning_map / distribution
- // pair, producing unusable signing credentials.
- const matchedProfiles = filterProfilesForApp(allMatchedProfiles, iosBundleId, importDistribution)
- const droppedCount = allMatchedProfiles.length - matchedProfiles.length
- return (
- ({
- label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
- // Key by UUID, NOT path. Disk-discovered profiles have a
- // unique path, but Apple-fetched profiles (from the D
- // no-match-recovery path) are synthesized with path=''.
- // UUID is unique for both kinds: disk profiles use the
- // mobileprovision UUID, synthesized ones use Apple's
- // profile resource ID.
- value: p.uuid,
- })),
- { label: '↩️ Back to identity selection', value: '__back__' },
- ]}
- onChange={(value) => {
- if (value === '__back__') {
- setStep('import-pick-identity')
- return
- }
- const profile = matchedProfiles.find(p => p.uuid === value)
- if (!profile)
- return
- // Defense in depth: verify bundleId + profileType match before
- // committing. The filter above should make this unreachable,
- // but if the filter regresses, we'd rather hard-fail than
- // silently save bad creds. Wildcard bundle ids
- // (`com.example.*`, bare `*`) are accepted via bundleIdMatches
- // so this guard stays in sync with the picker's filter — a
- // strict equality here would over-reject wildcards the filter
- // intentionally accepted upstream.
- if (!bundleIdMatches(profile.bundleId, iosBundleId)
- || (importDistribution && profile.profileType !== importDistribution)) {
- handleError(
- new Error(
- `Profile "${profile.name}" doesn't match this app: `
- + `bundle ${profile.bundleId} (expected ${iosBundleId}), `
- + `type ${profile.profileType} (expected ${importDistribution ?? 'any'}).`,
- ),
- 'import-pick-profile',
- )
- return
- }
- // Belt-and-suspenders: the upstream matchIdentitiesToProfiles
- // filter and Apple-fetched profile synthesizing should both
- // guarantee `profile.certificateSha1s` contains
- // `chosenIdentity.sha1`. But the file-picker recovery path
- // imports a .mobileprovision the user might have hand-created
- // in the portal — if they ticked the wrong cert in the cert
- // list there, we'd otherwise save credentials that the build
- // server can't actually sign with (private key from
- // chosenIdentity but profile only trusts a different cert).
- // Catch that here with a clear error rather than discovering
- // it during a build hours later.
- if (chosenIdentity && !profile.certificateSha1s.includes(chosenIdentity.sha1)) {
- const shownSha1s = profile.certificateSha1s.map(s => `${s.slice(0, 8)}…`).join(', ') || '(none listed)'
- handleError(
- new Error(
- `Profile "${profile.name}" doesn't trust your chosen certificate "${chosenIdentity.name}". `
- + `The profile's allowed-certs list contains ${profile.certificateSha1s.length} entr${profile.certificateSha1s.length === 1 ? 'y' : 'ies'} (SHA1: ${shownSha1s}); your cert's SHA1 starts with ${chosenIdentity.sha1.slice(0, 8)}…. `
- + `Either pick a different profile, or re-create this profile in the Apple Developer Portal and tick the right cert.`,
- ),
- 'import-pick-profile',
- )
- return
- }
- setChosenProfile(profile)
- addLog(`✔ Profile · ${profile.name}`)
- setStep('import-export-warning')
- }}
- />
- )
- })()}
-
- {/* Import: no-match recovery menu */}
- {step === 'import-no-match-recovery' && chosenIdentity && (() => {
- const hasAscKey = !!(p8ContentRef.current || p8PathRef.current)
- // D2 (create-profile-only) currently only knows how to create
- // IOS_APP_STORE profiles via apple-api.ts createProfile, which
- // hardcodes that profileType. For ad_hoc we'd need a separate
- // create path that calls Apple with IOS_APP_ADHOC. Until that
- // exists, hide the "Create" option for ad_hoc users so they
- // can't end up with an app_store profile saved under
- // CAPGO_IOS_DISTRIBUTION='ad_hoc'. Browser + Fetch still work.
- const canCreateProfile = importDistribution !== 'ad_hoc'
- return (
- = ({ appId, iosBundleIdInitial, initialProgres
// routes the manual case through the file picker. Two
// parallel rescan paths made the UX inconsistent with the
// instructions we render in the walkthrough.
- ...(canCreateProfile
- ? [{
- label: hasAscKey
- ? `✨ Create a new App Store profile for this cert via Apple`
- : `✨ Provide ASC API key, then create a new App Store profile for this cert`,
- value: 'create',
- }]
- : []),
- ...(canUseFilePicker()
- ? [{ label: `📁 Use a .mobileprovision file from disk`, value: 'provide-profile-path' }]
- : []),
- {
- label: `🌐 Open Apple Developer Portal (browse / create profiles manually)`,
- value: 'browser',
- },
- { label: '↩️ Back to identity selection', value: 'back' },
- ]}
- onChange={(value) => {
- if (value === 'browser') {
+ ...(canCreateProfile
+ ? [{
+ label: hasAscKey
+ ? `✨ Create a new App Store profile for this cert via Apple`
+ : `✨ Provide ASC API key, then create a new App Store profile for this cert`,
+ value: 'create',
+ }]
+ : []),
+ ...(canUseFilePicker()
+ ? [{ label: `📁 Use a .mobileprovision file from disk`, value: 'provide-profile-path' }]
+ : []),
+ {
+ label: `🌐 Open Apple Developer Portal (browse / create profiles manually)`,
+ value: 'browser',
+ },
+ { label: '↩️ Back to identity selection', value: 'back' },
+ ]}
+ onChange={(value) => {
+ if (value === 'browser') {
// Don't immediately open the portal — manual cert/profile
// creation on developer.apple.com is genuinely tricky
// (right cert type, allowed-certs list on the profile,
@@ -4238,964 +4380,969 @@ const OnboardingApp: FC = ({ appId, iosBundleIdInitial, initialProgres
// manual steps + steers the user toward the automatic
// "Create a new App Store profile via Apple" option
// when it's available (almost always the better pick).
- setStep('import-portal-explanation')
- return
- }
- if (value === 'provide-profile-path') {
- mobileprovisionPickerOpenedRef.current = false
- setStep('import-provide-profile-path')
- return
- }
- if (value === 'back') {
- setStep('import-pick-identity')
- return
- }
- if (value === 'create') {
- if (hasAscKey)
- setStep('import-create-profile-only')
- else {
- setPendingRecoveryAction('create-profile-only')
- setStep('api-key-instructions')
+ setStep('import-portal-explanation')
+ return
}
- }
- }}
- />
- )
- })()}
-
- {/* Import: native picker for a .mobileprovision file on disk */}
- {step === 'import-provide-profile-path' && (
-
-
- {`If the dialog doesn't appear, check behind other windows or in the menu bar.`}
-
- )}
-
- {/* Import: manual portal walkthrough — explains what to do on
+ if (value === 'provide-profile-path') {
+ mobileprovisionPickerOpenedRef.current = false
+ setStep('import-provide-profile-path')
+ return
+ }
+ if (value === 'back') {
+ setStep('import-pick-identity')
+ return
+ }
+ if (value === 'create') {
+ if (hasAscKey) {
+ setStep('import-create-profile-only')
+ }
+ else {
+ setPendingRecoveryAction('create-profile-only')
+ setStep('api-key-instructions')
+ }
+ }
+ }}
+ />
+ )
+ })()}
+
+ {/* Import: native picker for a .mobileprovision file on disk */}
+ {step === 'import-provide-profile-path' && (
+
+
+ {`If the dialog doesn't appear, check behind other windows or in the menu bar.`}
+
+ )}
+
+ {/* Import: manual portal walkthrough — explains what to do on
developer.apple.com and steers toward the automatic "Create new"
path or the file-picker path. Routed to from the recovery menu's
"🌐 Open Apple Developer Portal" option. */}
- {step === 'import-portal-explanation' && chosenIdentity && (() => {
- const canAutoCreate = importDistribution !== 'ad_hoc'
- // The walkthrough renders one of two flavours:
- // - app_store: the original "you CAN do this manually, but
- // here's an easier automatic option" framing, ending with a
- // Select that nudges toward the auto path.
- // - ad_hoc: an honest "this is complex, here's what's involved,
- // and you can email support if you want help" framing, with a
- // plain Open Portal option (no "anyway" — there's no
- // automatic alternative to contrast with), the file-picker
- // return path, and a Capgo-support breadcrumb. The manual
- // walkthrough below stops at step 7 (download + come back),
- // intentionally skipping the device-registration step that
- // ad_hoc profiles require — that step varies wildly by team
- // and is exactly the kind of thing support can help with.
- return (
-
-
- {canAutoCreate
- ? 'You can do this manually in the Apple Developer Portal — but the automatic path is much easier.'
- : `Ad-hoc distribution is genuinely fiddly (you also need to register every target device on Apple's side). Here's what's involved — and how to get help if you're stuck.`}
-
-
- {/* The ad_hoc "want help?" breadcrumb fires at the moment the
+ {step === 'import-portal-explanation' && chosenIdentity && (() => {
+ const canAutoCreate = importDistribution !== 'ad_hoc'
+ // The walkthrough renders one of two flavours:
+ // - app_store: the original "you CAN do this manually, but
+ // here's an easier automatic option" framing, ending with a
+ // Select that nudges toward the auto path.
+ // - ad_hoc: an honest "this is complex, here's what's involved,
+ // and you can email support if you want help" framing, with a
+ // plain Open Portal option (no "anyway" — there's no
+ // automatic alternative to contrast with), the file-picker
+ // return path, and a Capgo-support breadcrumb. The manual
+ // walkthrough below stops at step 7 (download + come back),
+ // intentionally skipping the device-registration step that
+ // ad_hoc profiles require — that step varies wildly by team
+ // and is exactly the kind of thing support can help with.
+ return (
+
+
+ {canAutoCreate
+ ? 'You can do this manually in the Apple Developer Portal — but the automatic path is much easier.'
+ : `Ad-hoc distribution is genuinely fiddly (you also need to register every target device on Apple's side). Here's what's involved — and how to get help if you're stuck.`}
+
+
+ {/* The ad_hoc "want help?" breadcrumb fires at the moment the
user picks Ad Hoc on the distribution-mode step (yellow log
entries), not here. Surfacing it on the recovery walkthrough
meant the user only saw the offer AFTER they'd already
started fumbling — too late. The log lines stay visible in
the side log throughout the rest of the wizard. */}
- {`What you'd need to do manually:`}
-
- 1. Sign in at developer.apple.com/account/resources/profiles/list.
-
- 2. Select the correct team (top right) —
- {' '}
- {chosenIdentity.teamId}
- {chosenIdentity.teamName ? ` (${chosenIdentity.teamName})` : ''}
- .
-
-
- 3. Click
- {' '}
- +
- {' '}
- to create a new profile. Pick
- {' '}
- {importDistribution === 'ad_hoc' ? 'Ad Hoc' : 'App Store'}
- {' '}
- under
- {' '}
- Distribution
- .
-
-
- 4. Pick the App ID matching
- {' '}
- {iosBundleId}
- {`. Create it first if it doesn't exist.`}
-
-
- 5. In the "Certificates" step, tick the cert matching
- {' '}
- {chosenIdentity.name}
- . If multiple are listed, pick carefully — we re-verify the cert SHA1 in the next step.
-
- {!canAutoCreate && (
+ {`What you'd need to do manually:`}
+
+ 1. Sign in at developer.apple.com/account/resources/profiles/list.
+
+ 2. Select the correct team (top right) —
+ {' '}
+ {chosenIdentity.teamId}
+ {chosenIdentity.teamName ? ` (${chosenIdentity.teamName})` : ''}
+ .
+
- 6. In the "Devices" step, tick every device UDID that should be able to run this build. (This is the ad-hoc-specific step. Devices have to be registered under
+ 3. Click
+ {' '}
+ +
+ {' '}
+ to create a new profile. Pick
+ {' '}
+ {importDistribution === 'ad_hoc' ? 'Ad Hoc' : 'App Store'}
{' '}
- developer.apple.com/account/resources/devices/list
+ under
{' '}
- first.)
+ Distribution
+ .
- )}
-
- {canAutoCreate ? '6. ' : '7. '}
- Name + Generate + Download. The .mobileprovision file lands in your Downloads folder.
-
-
- {canAutoCreate ? '7. ' : '8. '}
- Come back here and pick
- {' '}
- 📁 Use a .mobileprovision file from disk
- .
-
-
-
- {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.
+
+ 4. Pick the App ID matching
+ {' '}
+ {iosBundleId}
+ {`. Create it first if it doesn't exist.`}
+
+
+ 5. In the "Certificates" step, tick the cert matching
+ {' '}
+ {chosenIdentity.name}
+ . If multiple are listed, pick carefully — we re-verify the cert SHA1 in the next step.
+
+ {!canAutoCreate && (
+
+ 6. In the "Devices" step, tick every device UDID that should be able to run this build. (This is the ad-hoc-specific step. Devices have to be registered under
+ {' '}
+ developer.apple.com/account/resources/devices/list
+ {' '}
+ first.)
+
+ )}
+
+ {canAutoCreate ? '6. ' : '7. '}
+ Name + Generate + Download. The .mobileprovision file lands in your Downloads folder.
+
+
+ {canAutoCreate ? '7. ' : '8. '}
+ Come back here and pick
+ {' '}
+ 📁 Use a .mobileprovision file from disk
+ .
-
- )}
- {
- 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.
+
+
+
+ )}
+ {
+ 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')
+ setStep('import-no-match-recovery')
+ return
+ }
setStep('import-no-match-recovery')
- return
- }
- setStep('import-no-match-recovery')
- }}
- />
-
- )
- })()}
-
- {/* Import: D2 — creating a new profile via Apple for the existing cert */}
- {step === 'import-create-profile-only' && }
-
- {/* Import: export warning (heads-up before the one Keychain dialog) */}
- {step === 'import-export-warning' && chosenIdentity && (
- {
- if (value === 'go') {
+ }}
+ />
+
+ )
+ })()}
+
+ {/* Import: D2 — creating a new profile via Apple for the existing cert */}
+ {step === 'import-create-profile-only' && }
+
+ {/* Import: export warning (heads-up before the one Keychain dialog) */}
+ {step === 'import-export-warning' && chosenIdentity && (
+ {
+ if (value === 'go') {
// Go straight to the export step; the precompiled helper is
// resolved and signature-verified there.
- setStep('import-exporting')
- }
- else if (value === 'back') {
+ setStep('import-exporting')
+ }
+ else if (value === 'back') {
// Back goes to profile selection (distribution mode is now upstream of this step)
- setStep('import-pick-profile')
- }
- else {
- exitOnboarding('Exiting. Re-run `build init` whenever you\'re ready.')
- }
- }}
- />
- )}
-
- {/* Import: exporting (the one Keychain prompt happens here) */}
- {step === 'import-exporting' && }
-
- {/* API key instructions + .p8 input */}
- {step === 'api-key-instructions' && (
- {
- if (value === 'picker') {
- setStep('p8-method-select')
- }
- else {
- setStep('input-p8-path')
- }
- }}
- onPathSubmit={async (value) => {
- const filePath = value.replace(/^~/, process.env.HOME || '')
- try {
- const content = await readFile(filePath, 'utf-8')
- setP8Path(filePath)
- setP8Content(content)
- const extracted = extractKeyIdFromPath(filePath)
- if (extracted)
- setKeyId(extracted)
- addLog(`✔ Key file found · ${filePath}`)
- // Persist the extracted keyId too, so a quit-before-confirm resume
- // restores it instead of showing the empty placeholder.
- void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined })
- setStep('input-key-id')
- }
- catch {
- handleError(new Error(`File not found: ${filePath}`), 'api-key-instructions')
- }
- }}
- />
- )}
-
- {/* File picker opening */}
- {step === 'p8-method-select' && }
-
- {/* Manual .p8 path input */}
- {step === 'input-p8-path' && (
- {
- const filePath = value.replace(/^~/, process.env.HOME || '')
- try {
- const content = await readFile(filePath, 'utf-8')
- setP8Path(filePath)
- setP8Content(content)
- const extracted = extractKeyIdFromPath(filePath)
- if (extracted)
- setKeyId(extracted)
- addLog(`✔ Key file found · ${filePath}`)
- // Persist the extracted keyId too, so a quit-before-confirm resume
- // restores it instead of showing the empty placeholder.
- void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined })
- setStep('input-key-id')
- }
- catch {
- handleError(new Error(`File not found: ${value}`), 'input-p8-path')
- }
- }}
- />
- )}
-
- {/* Key ID */}
- {step === 'input-key-id' && (
- {
+ setStep('import-pick-profile')
+ }
+ else {
+ exitOnboarding('Exiting. Re-run `build init` whenever you\'re ready.')
+ }
+ }}
+ />
+ )}
+
+ {/* Import: exporting (the one Keychain prompt happens here) */}
+ {step === 'import-exporting' && }
+
+ {/* API key instructions + .p8 input */}
+ {step === 'api-key-instructions' && (
+ {
+ if (value === 'picker') {
+ setStep('p8-method-select')
+ }
+ else {
+ setStep('input-p8-path')
+ }
+ }}
+ onPathSubmit={async (value) => {
+ const filePath = value.replace(/^~/, process.env.HOME || '')
+ try {
+ const content = await readFile(filePath, 'utf-8')
+ setP8Path(filePath)
+ setP8Content(content)
+ const extracted = extractKeyIdFromPath(filePath)
+ if (extracted)
+ setKeyId(extracted)
+ addLog(`✔ Key file found · ${filePath}`)
+ // Persist the extracted keyId too, so a quit-before-confirm resume
+ // restores it instead of showing the empty placeholder.
+ void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined })
+ setStep('input-key-id')
+ }
+ catch {
+ handleError(new Error(`File not found: ${filePath}`), 'api-key-instructions')
+ }
+ }}
+ />
+ )}
+
+ {/* File picker opening */}
+ {step === 'p8-method-select' && }
+
+ {/* Manual .p8 path input */}
+ {step === 'input-p8-path' && (
+ {
+ const filePath = value.replace(/^~/, process.env.HOME || '')
+ try {
+ const content = await readFile(filePath, 'utf-8')
+ setP8Path(filePath)
+ setP8Content(content)
+ const extracted = extractKeyIdFromPath(filePath)
+ if (extracted)
+ setKeyId(extracted)
+ addLog(`✔ Key file found · ${filePath}`)
+ // Persist the extracted keyId too, so a quit-before-confirm resume
+ // restores it instead of showing the empty placeholder.
+ void savePartialProgress({ p8Path: filePath, keyId: extracted || undefined })
+ setStep('input-key-id')
+ }
+ catch {
+ handleError(new Error(`File not found: ${value}`), 'input-p8-path')
+ }
+ }}
+ />
+ )}
+
+ {/* Key ID */}
+ {step === 'input-key-id' && (
+ {
// `value || keyId` reuses the detected key ID when the user just
// presses Enter; the trim+guard rejects an empty submission in the
// no-detection case (keyId='' makes the fallback a no-op).
- const finalKeyId = (value || keyId).trim()
- if (!finalKeyId)
- return
- setKeyId(finalKeyId)
- upsertLog('✔ Key ID · ', `✔ Key ID · ${finalKeyId}`)
- void savePartialProgress({ keyId: finalKeyId })
- setStep('input-issuer-id')
- }}
- />
- )}
-
- {/* 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 === '__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()
- }
- }}
- />
- )}
-
- {/* Deleting duplicate profiles */}
- {step === 'deleting-duplicate-profiles' && (
-
- )}
-
- {/* Saving credentials */}
- {step === 'saving-credentials' && }
-
- {step === 'detecting-ci-secrets' && }
-
- {step === 'ci-secrets-setup' && (
- {
- setStep(value === 'retry' ? 'detecting-ci-secrets' : 'build-complete')
- }}
- />
- )}
-
- {step === 'ci-secrets-target-select' && (
- ({
- label: target.provider === 'github' ? 'GitHub Actions repository secrets' : 'GitLab CI/CD variables',
- value: target.provider,
- })),
- { label: 'Skip', value: 'skip' },
- ]}
- dense={dense}
- onChange={(value) => {
- if (value === 'skip') {
- setStep('build-complete')
- return
- }
- const target = ciSecretTargets.find(candidate => candidate.provider === value) || null
- setCiSecretTarget(target)
- if (!target) {
- setStep('build-complete')
- return
- }
- // GitHub routes into the new 3-option GitHub Actions prompt
- // (secrets + workflow / secrets-only / no); GitLab keeps the legacy
- // 2-option ask-ci-secrets flow.
- setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets')
- }}
- />
- )}
-
- {step === 'ask-ci-secrets' && (
- {
- setStep(value === 'yes' ? 'checking-ci-secrets' : 'build-complete')
- }}
- />
- )}
-
- {step === 'ask-github-actions-setup' && (
-
-
-
- Set up GitHub Actions for you?
-
- Capgo can push your
- {' '}
- {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.
-
-
-
+ )}
+
+ {/* 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.
-
-
-
+ )}
+
+ {/* Saving credentials */}
+ {step === 'saving-credentials' && }
+
+ {step === 'detecting-ci-secrets' && }
+
+ {step === 'ci-secrets-setup' && (
+ {
+ setStep(value === 'retry' ? 'detecting-ci-secrets' : 'build-complete')
+ }}
+ />
+ )}
+
+ {step === 'ci-secrets-target-select' && (
+ ({
+ label: target.provider === 'github' ? 'GitHub Actions repository secrets' : 'GitLab CI/CD variables',
+ value: target.provider,
+ })),
+ { label: 'Skip', value: 'skip' },
]}
+ dense={dense}
onChange={(value) => {
- if (value === 'yes') {
- setEnvExportTargetPath(defaultExportPath(appId, 'ios'))
- setStep('exporting-env')
+ if (value === 'skip') {
+ setStep('build-complete')
return
}
- setStep('build-complete')
+ const target = ciSecretTargets.find(candidate => candidate.provider === value) || null
+ setCiSecretTarget(target)
+ if (!target) {
+ setStep('build-complete')
+ return
+ }
+ // GitHub routes into the new 3-option GitHub Actions prompt
+ // (secrets + workflow / secrets-only / no); GitLab keeps the legacy
+ // 2-option ask-ci-secrets flow.
+ setStep(target.provider === 'github' ? 'ask-github-actions-setup' : 'ask-ci-secrets')
}}
/>
-
- )}
-
- {step === 'exporting-env' && (
-
-
-
- )}
-
- {step === 'confirm-env-export-overwrite' && (
-
-
- {envExportTargetPath}
- {' '}
- already exists.
-
- Replace it with a fresh export, or skip?
-
- {
- 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.
{
- setSelectedPackageManager(value as PackageManager)
- setStep('pick-build-script')
+ if (value === 'no') {
+ setSetupMode('declined')
+ setStep('ask-export-env')
+ return
+ }
+ setSetupMode(value as 'with-workflow' | 'secrets-only')
+ setStep('checking-ci-secrets')
}}
/>
- )
- })()}
-
- {step === 'pick-build-script' && (
-
- Which script builds your web assets?
-
- Capgo will run this before invoking
- {' '}
- capgo build request
- {' '}
- in the workflow. Pick the script you use locally to produce the web build (typically into capacitor.config webDir, e.g. dist/).
-
-
- {
- 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.
+
+
+ {
+ if (value === 'yes') {
+ setEnvExportTargetPath(defaultExportPath(appId, 'ios'))
+ setStep('exporting-env')
return
- setBuildScriptChoice({ type: 'custom', command: cleaned })
- setStep('preview-workflow-file')
+ }
+ setStep('build-complete')
}}
/>
-
- )}
-
- {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 (
+ )}
+
+ {step === 'exporting-env' && (
+
+
+
+ )}
+
+ {step === 'confirm-env-export-overwrite' && (
-
-
- What should we do with {WORKFLOW_PATH}?
+
+ {envExportTargetPath}
+ {' '}
+ already exists.
+
+ Replace it with a fresh export, or skip?
+
+ {
+ 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 === 'view') {
- trackWorkflowEvent('workflow-preview-action', { decision: 'view' })
- trackWorkflowEvent('workflow-diff-opened', { decision: 'view' })
- setStep('view-workflow-diff')
+ setSelectedPackageManager(value as PackageManager)
+ setStep('pick-build-script')
+ }}
+ />
+
+ )
+ })()}
+
+ {step === 'pick-build-script' && (
+
+ Which script builds your web assets?
+
+ Capgo will run this before invoking
+ {' '}
+ capgo build request
+ {' '}
+ in the workflow. Pick the script you use locally to produce the web build (typically into capacitor.config webDir, e.g. dist/).
+
+
+ {
+ 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}
+ ?
+
+ {
+ if (value === 'view') {
+ trackWorkflowEvent('workflow-preview-action', { decision: 'view' })
+ trackWorkflowEvent('workflow-diff-opened', { decision: 'view' })
+ setStep('view-workflow-diff')
+ return
+ }
+ trackWorkflowEvent('workflow-preview-action', { decision: value === 'write' ? 'write' : 'cancel' })
+ setPreviewDiff([])
+ setStep(value === 'write' ? 'writing-workflow-file' : 'build-complete')
+ }}
/>
- {replaceCount > 0 && (
+
+ )
+ })()}
+ {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
+ 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:'}
+
- ⚠ `gh secret set` overwrites silently — replaced values cannot be recovered.
+ ({
+ name: entry.key,
+ status: existingSet.has(entry.key) ? 'REPLACE' : 'NEW',
+ }))}
+ />
- )}
+ {replaceCount > 0 && (
+
+ ⚠ `gh secret set` overwrites silently — replaced values cannot be recovered.
+
+ )}
+
+ )
+ })()
+ )}
+ {step === 'confirm-secrets-push' && (
+ <>
+
+ {
+ setStep(value === 'confirm' ? 'uploading-ci-secrets' : 'build-complete')
+ }}
+ />
- )
- })()
- )}
- {step === 'confirm-secrets-push' && (
- <>
-
- {
- setStep(value === 'confirm' ? 'uploading-ci-secrets' : 'build-complete')
- }}
+ >
+ )}
+
+ {step === 'confirm-ci-secret-overwrite' && (
+ {
+ setStep(value === 'replace' ? 'uploading-ci-secrets' : 'build-complete')
+ }}
+ />
+ )}
+
+ {step === 'uploading-ci-secrets' && (
+
+
- >
- )}
-
- {step === 'confirm-ci-secret-overwrite' && (
- {
- setStep(value === 'replace' ? 'uploading-ci-secrets' : 'build-complete')
- }}
- />
- )}
-
- {step === 'uploading-ci-secrets' && (
-
- {
+ setStep(value === 'retry' ? (ciSecretTarget ? 'checking-ci-secrets' : 'detecting-ci-secrets') : 'build-complete')
+ }}
/>
-
- )}
-
- {step === 'ci-secrets-failed' && (
- {
- setStep(value === 'retry' ? (ciSecretTarget ? 'checking-ci-secrets' : 'detecting-ci-secrets') : 'build-complete')
- }}
- />
- )}
-
- {/* Ask to build */}
- {step === 'ask-build' && (
- {
- if (value === 'yes') {
- setStep('requesting-build')
- }
- else {
- setStep('build-complete')
- }
- }}
- />
- )}
+ )}
- {/* Requesting build: handled by the FullscreenBuildOutput early return
+ {/* Ask to build */}
+ {step === 'ask-build' && (
+ {
+ if (value === 'yes') {
+ setStep('requesting-build')
+ }
+ else {
+ setStep('build-complete')
+ }
+ }}
+ />
+ )}
+
+ {/* Requesting build: handled by the FullscreenBuildOutput early return
above (fullscreen takeover, bypasses the too-small gate) — nothing
renders here in the measured body. */}
- {/* AI debug — ask the user whether to send the captured log */}
- {step === 'ai-analysis-prompt' && (
- {
- if (value === 'support') {
- await handleSupport('ai-analysis-prompt')
- return
- }
- if (value === 'debug') {
- setStep('ai-analysis-running')
- }
- else {
- if (aiJobId) {
- await trackAiAnalysisChoice({
- apikey: resolvedApiKeyRef.current ?? apikey ?? '',
- orgId: resolvedOrgId ?? '',
- appId,
- platform: 'ios',
- jobId: aiJobId,
- choice: 'skip',
- triggeredBy: 'onboarding',
- }).catch(() => { /* telemetry never breaks the wizard */ })
+ {/* AI debug — ask the user whether to send the captured log */}
+ {step === 'ai-analysis-prompt' && (
+ {
+ if (value === 'support') {
+ await handleSupport('ai-analysis-prompt')
+ return
}
- setStep('build-complete')
- }
- }}
- />
- )}
+ if (value === 'debug') {
+ setStep('ai-analysis-running')
+ }
+ else {
+ if (aiJobId) {
+ await trackAiAnalysisChoice({
+ apikey: resolvedApiKeyRef.current ?? apikey ?? '',
+ orgId: resolvedOrgId ?? '',
+ appId,
+ platform: 'ios',
+ jobId: aiJobId,
+ choice: 'skip',
+ triggeredBy: 'onboarding',
+ }).catch(() => { /* telemetry never breaks the wizard */ })
+ }
+ setStep('build-complete')
+ }
+ }}
+ />
+ )}
- {/* AI debug — spinner while the edge function is running */}
- {step === 'ai-analysis-running' && }
+ {/* AI debug — spinner while the edge function is running */}
+ {step === 'ai-analysis-running' && }
- {step === 'support-uploading' && (
-
-
-
- )}
+ {step === 'support-uploading' && (
+
+
+
+ )}
- {/* AI debug — render the diagnosis (or fallback message), then offer
+ {/* AI debug — render the diagnosis (or fallback message), then offer
retry-or-skip. Retry transitions back to 'requesting-build' so the
user can rebuild after applying the AI's fix in another terminal,
without re-running the credential wizard. Capped at MAX_AI_RETRIES. */}
- {step === 'ai-analysis-result' && (
- 0}
- retriesLeft={MAX_AI_RETRIES - aiRetryCount}
- maxRetries={MAX_AI_RETRIES}
- dense={dense}
- onChange={async (value) => {
- if (value === 'support') {
- await handleSupport('ai-analysis-result')
- return
- }
- if (value === 'reread') {
+ {step === 'ai-analysis-result' && (
+ 0}
+ retriesLeft={MAX_AI_RETRIES - aiRetryCount}
+ maxRetries={MAX_AI_RETRIES}
+ dense={dense}
+ onChange={async (value) => {
+ if (value === 'support') {
+ await handleSupport('ai-analysis-result')
+ return
+ }
+ if (value === 'reread') {
// Re-open the fullscreen scroll viewer (alt buffer has no
// scrollback, so this is the only way to re-read).
- setStep('ai-analysis-result-scroll')
- return
- }
- if (value === 'retry') {
+ setStep('ai-analysis-result-scroll')
+ return
+ }
+ if (value === 'retry') {
// Track the retry intent before we tear down the AI state so
// the choice event carries the per-attempt context.
- if (aiJobId) {
- await trackAiAnalysisChoice({
- apikey: resolvedApiKeyRef.current ?? apikey ?? '',
- orgId: resolvedOrgId ?? '',
- appId,
- platform: 'ios',
- jobId: aiJobId,
- choice: 'retry',
- triggeredBy: 'onboarding',
- }).catch(() => { /* telemetry never breaks the wizard */ })
- // Free the captured log for the previous attempt; the next
- // attempt's `requestBuildInternal` will create a new file
- // tied to a new builder_job_id.
- void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ })
+ if (aiJobId) {
+ await trackAiAnalysisChoice({
+ apikey: resolvedApiKeyRef.current ?? apikey ?? '',
+ orgId: resolvedOrgId ?? '',
+ appId,
+ platform: 'ios',
+ jobId: aiJobId,
+ choice: 'retry',
+ triggeredBy: 'onboarding',
+ }).catch(() => { /* telemetry never breaks the wizard */ })
+ // Free the captured log for the previous attempt; the next
+ // attempt's `requestBuildInternal` will create a new file
+ // tied to a new builder_job_id.
+ void releaseCapturedLogs(aiJobId).catch(() => { /* best-effort */ })
+ }
+ // Reset AI state so the next failure starts clean. The fit
+ // check (and possible scroll-viewer route) will re-evaluate
+ // against the new analysis text.
+ setAiJobId(null)
+ setAiAnalysisText(null)
+ setAiResult(null)
+ setAiViewedFull(false)
+ setAiRetryCount(prev => prev + 1)
+ setStep('requesting-build')
+ return
}
- // Reset AI state so the next failure starts clean. The fit
- // check (and possible scroll-viewer route) will re-evaluate
- // against the new analysis text.
- setAiJobId(null)
- setAiAnalysisText(null)
- setAiResult(null)
- setAiViewedFull(false)
- setAiRetryCount(prev => prev + 1)
- setStep('requesting-build')
- return
- }
- // 'skip' (with retries available) or 'continue' (none left).
- setStep('build-complete')
- }}
- />
- )}
+ // 'skip' (with retries available) or 'continue' (none left).
+ setStep('build-complete')
+ }}
+ />
+ )}
- {/* (ai-analysis-result-scroll renders as a fullscreen early return above.) */}
+ {/* (ai-analysis-result-scroll renders as a fullscreen early return above.) */}
- {/* Contact-support confirmation gate — tells the user everything that's
+ {/* Contact-support confirmation gate — tells the user everything that's
about to happen (logs saved, revealed in Finder on macOS, email
opened) and waits for an explicit Yes before the mail client opens. */}
- {step === 'support-confirm' && (
-
- Email Capgo support
- {supportConfirmMessage}
- {
- 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}
+ {
+ 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
+ }
+ const resolve = supportConfirmResolveRef.current
+ supportConfirmResolveRef.current = null
+ resolve?.(value === 'yes')
+ }}
+ />
+
+ )}
+
+ {/* Error with retry */}
+ {step === 'error' && error && (
+ {
+ if (value === 'support') {
+ await handleSupport()
}
- const resolve = supportConfirmResolveRef.current
- supportConfirmResolveRef.current = null
- resolve?.(value === 'yes')
- }}
- />
-
- )}
-
- {/* Error with retry */}
- {step === 'error' && error && (
- {
- if (value === 'support') {
- await handleSupport()
- }
- else if (value === 'ai') {
+ else if (value === 'ai') {
// A captured build-failure log is available — route into the
// existing AI-analysis prompt (unchanged from today).
- setStep('ai-analysis-prompt')
- }
- else if (value === 'retry') {
- setError(null)
- errorCategoryRef.current = undefined
- pickerOpenedRef.current = false
- mobileprovisionPickerOpenedRef.current = false
- if (retryStep)
- setStep(retryStep)
- }
- else if (value === 'restart') {
+ setStep('ai-analysis-prompt')
+ }
+ else if (value === 'retry') {
+ setError(null)
+ errorCategoryRef.current = undefined
+ pickerOpenedRef.current = false
+ mobileprovisionPickerOpenedRef.current = false
+ if (retryStep)
+ setStep(retryStep)
+ }
+ else if (value === 'restart') {
// Centralised reset (also clears the per-identity Apple-side
// availability map + the iOS bundle id confirmation gate — both
// missing from the previous inline version). The log message
// stays error-recovery specific.
- await resetForFreshStart()
- addLog('↩️ Onboarding reset — starting fresh', 'yellow')
- setStep('welcome')
- }
- else {
- setError(`Run \`${buildInitCommand}\` to resume.`)
- exitOnboarding()
- }
- }}
- />
- )}
-
- {/* Done */}
- {step === 'build-complete' && (
-
- )}
+ await resetForFreshStart()
+ addLog('↩️ Onboarding reset — starting fresh', 'yellow')
+ setStep('welcome')
+ }
+ else {
+ setError(`Run \`${buildInitCommand}\` to resume.`)
+ exitOnboarding()
+ }
+ }}
+ />
+ )}
+
+ {/* Done */}
+ {step === 'build-complete' && (
+
+ )}
)
diff --git a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx
index 5ccb2fb654..30d817941f 100644
--- a/cli/src/build/onboarding/ui/steps/ios-credentials.tsx
+++ b/cli/src/build/onboarding/ui/steps/ios-credentials.tsx
@@ -117,6 +117,73 @@ export const SetupMethodSelectStep: FC = ({ dense =
)
+// ── p8-source-select ──────────────────────────────────────────────────────────
+// First fork of the ASC API-key step: does the user already have a .p8 file? If
+// not — and we can drive the guided macOS helper — offer to create one for them.
+export interface P8SourceSelectStepProps {
+ dense?: boolean
+ /** True when the guided macOS helper is available (macOS + binary present). */
+ canAutomate: boolean
+ onChange: (value: string) => void | Promise
+}
+
+export const P8SourceSelectStep: FC = ({ dense = false, canAutomate, onChange }) => (
+
+
+ Do you already have an App Store Connect API key (.p8 file)?
+
+ {!dense && }
+
+
+)
+
+// ── 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 && }
+
+ {!dense && }
+
+ Automated opens a window, walks you through Apple's UI, and captures the key — no copy-paste.
+
+
+)
+
+// ── asc-key-generating ────────────────────────────────────────────────────────
+// Spinner shown while the guided macOS helper runs in its own window.
+export const AscKeyGeneratingStep: FC = () => (
+
+
+
+)
+
// ── api-key-instructions ─────────────────────────────────────────────────────
// `canUseFilePicker` decides which control to show: the picker/manual fork
// (Select) or a direct path input (FilteredTextInput). The submit handler for
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/src/support/redact.ts b/cli/src/support/redact.ts
index 7f46eb85b9..d78203706d 100644
--- a/cli/src/support/redact.ts
+++ b/cli/src/support/redact.ts
@@ -9,8 +9,12 @@ const PATTERNS: Array<{ re: RegExp, replace: string }> = [
{ re: /(capgkey\s*[=:]\s*)[\w-]{6,}/gi, replace: '$1[REDACTED]' },
// generic key/secret/token/password = value
{ re: /\b(api[_-]?key|secret|token|password|passwd|pwd)(\s*[=:]\s*)["']?[\w.\-+/=]{6,}["']?/gi, replace: '$1$2[REDACTED]' },
- // JSON-style secrets dumped from raw provider (Apple App Store Connect / Google Play) API error bodies
- { re: /("(?:access_token|refresh_token|id_token|client_secret|private_key|api[_-]?key|token|secret|password)"\s*:\s*")[^"]+(")/gi, replace: '$1[REDACTED]$2' },
+ // JSON-style secrets dumped from raw provider (Apple App Store Connect / Google
+ // Play) API error bodies, and structured props on the ASC helper's `log` lines.
+ // `p8`/`pem` cover a stray private-key-bearing prop the patterns above miss; the
+ // closing quote anchors the key, so `"p8Path"` (a useful, non-secret path) is
+ // left intact.
+ { re: /("(?:access_token|refresh_token|id_token|client_secret|private_key|apple_key_content|api[_-]?key|token|secret|password|p8|pem)"\s*:\s*")[^"]+(")/gi, replace: '$1[REDACTED]$2' },
]
export function redactSecrets(text: string): string {
diff --git a/cli/test/test-asc-key-protocol.mjs b/cli/test/test-asc-key-protocol.mjs
new file mode 100644
index 0000000000..5261dd248a
--- /dev/null
+++ b/cli/test/test-asc-key-protocol.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+import assert from 'node:assert/strict'
+import {
+ ASC_KEY_CHANNEL,
+ ASC_LOG_LEVELS,
+ 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')
+}
+
+// 10. parse a valid log line (diagnostics → internal support log)
+{
+ const line = parseAscProtocolLine('{"capgoAscKey":1,"kind":"log","ts":420,"runId":"r2","level":"warn","message":"issuer_id scrape returned no value","props":{"attempt":3,"url":"https://appstoreconnect.apple.com/access/integrations/api"}}')
+ assert.ok(line, 'log line should parse')
+ assert.equal(line.kind, 'log')
+ assert.equal(line.level, 'warn')
+ assert.equal(line.message, 'issuer_id scrape returned no value')
+ assert.equal(line.props.attempt, 3)
+ console.log('✅ parses a valid log line')
+}
+
+// 11. log line: missing message is dropped; unknown/absent level defaults to info
+{
+ assert.equal(parseAscProtocolLine('{"capgoAscKey":1,"kind":"log","props":{}}'), null, 'no message => not a log line')
+ const noLevel = parseAscProtocolLine('{"capgoAscKey":1,"kind":"log","message":"hi"}')
+ assert.equal(noLevel.level, 'info', 'absent level defaults to info')
+ const oddLevel = parseAscProtocolLine('{"capgoAscKey":1,"kind":"log","level":"verbose","message":"hi"}')
+ assert.equal(oddLevel.level, 'info', 'unknown level falls back to info')
+ assert.deepEqual([...ASC_LOG_LEVELS], ['debug', 'info', 'warn', 'error'])
+ console.log('✅ log line tolerates missing message / odd level')
+}
+
+console.log('\n🎉 All asc-key protocol tests passed')
diff --git a/cli/test/test-onboarding-progress.mjs b/cli/test/test-onboarding-progress.mjs
index da079e7433..b88d6834fb 100644
--- a/cli/test/test-onboarding-progress.mjs
+++ b/cli/test/test-onboarding-progress.mjs
@@ -203,6 +203,37 @@ t('getResumeStep returns creating-profile for create-new with cert, no profile',
assert.equal(getResumeStep(progress), 'creating-profile')
})
+// A user who chose the guided macOS helper (p8CreateMethod=automated) and quit
+// before it captured a key must resume ON the helper, not the manual .p8 picker.
+t('getResumeStep returns asc-key-generating for create-new automated with no inputs', () => {
+ const progress = makeProgress({ setupMethod: 'create-new', p8CreateMethod: 'automated' })
+ assert.equal(getResumeStep(progress), 'asc-key-generating')
+})
+
+// Manual choosers (and legacy/undefined) still resume on the .p8 instructions.
+t('getResumeStep returns api-key-instructions for create-new manual with no inputs', () => {
+ const progress = makeProgress({ setupMethod: 'create-new', p8CreateMethod: 'manual' })
+ assert.equal(getResumeStep(progress), 'api-key-instructions')
+})
+t('getResumeStep returns api-key-instructions for create-new with no p8CreateMethod', () => {
+ const progress = makeProgress({ setupMethod: 'create-new' })
+ assert.equal(getResumeStep(progress), 'api-key-instructions')
+})
+
+// Once the helper captured the key (all three inputs persisted), resume goes to
+// verifying-key — the partial-input branch wins over the automated re-launch, so
+// the helper is NOT re-run for work already done.
+t('getResumeStep returns verifying-key for create-new automated once inputs are saved', () => {
+ const progress = makeProgress({
+ setupMethod: 'create-new',
+ p8CreateMethod: 'automated',
+ keyId: 'K',
+ issuerId: 'I',
+ p8Path: '/Users/x/.appstoreconnect/private_keys/AuthKey_K.p8',
+ })
+ assert.equal(getResumeStep(progress), 'verifying-key')
+})
+
// Partial .p8 inputs (the original branches) keep working — these resume
// at the furthest input step, regardless of distribution mode.
t('getResumeStep resumes at verifying-key when import has full .p8 inputs', () => {
diff --git a/cli/test/test-support-redact.mjs b/cli/test/test-support-redact.mjs
index 46f348803c..d26a71a432 100644
--- a/cli/test/test-support-redact.mjs
+++ b/cli/test/test-support-redact.mjs
@@ -33,3 +33,11 @@ t('redacts JSON-style secrets from raw API error bodies', () => {
assert.ok(out.includes('[REDACTED]'))
assert.ok(out.includes('"detail":"x"')) // non-secret fields preserved
})
+
+t('redacts p8/pem-keyed values (ASC helper log props) but keeps p8Path', () => {
+ const out = redactSecrets('asc-helper error: bad key {"p8":"PRIVKEYBASE64SECRET","pem":"PEMSECRET","p8Path":"/Users/x/AuthKey_ABC.p8","attempt":2}')
+ assert.ok(!out.includes('PRIVKEYBASE64SECRET'))
+ assert.ok(!out.includes('PEMSECRET'))
+ assert.ok(out.includes('/Users/x/AuthKey_ABC.p8'), 'the .p8 file PATH is useful for support, not a secret — kept')
+ assert.ok(out.includes('"attempt":2'), 'non-secret context preserved')
+})