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}`} - - { // 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') + }} + /> + + ) + } - // ── 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. + + + 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?`} + + + { - setGateActionSeq(s => s + 1) - if (value === 'recheck') - void continueCreateApp() - else if (value === 'reopen') - void openCreatePage() - else - cancelGate('create-app') - }} - /> - - ) - } - return ( - - - {`No App Store app exists for ${releaseId}${attemptMarker}`} - - - {'Your project builds '} - {releaseId} - {`, but there's no App Store Connect app with that bundle ID yet. An app_store build needs one to upload to.`} - - - - {alreadyRegistered - ? `The identifier ${releaseId} is already registered in your Developer account — select it when creating the app.` - : `We'll register the identifier ${releaseId} (so it's selectable) when you open the create-app page.`} - - {'The App Store Connect API cannot create apps — this is a one-time manual step on the web.'} - - - ({ - label: `${a.name} — ${a.bundleId}`, - value: a.bundleId, - })), - { label: '➕ None of these — my build ID is correct, create a new app', value: '__create_new__' }, - ]} - onChange={(value) => { - if (value === '__create_new__') { - trackVerifyEvent('iOS App Verify Picked', '👆', { matches_build_id: false, chose_create_new: true }) - setVerifyPath('create-app') - return - } - const chosen = verifyApps.find(a => a.bundleId === value) ?? null - trackVerifyEvent('iOS App Verify Picked', '👆', { - matches_build_id: value === releaseId, - chose_create_new: false, - }) - if (chosen && chosen.bundleId === releaseId) { - // Already matches — pass straight through (defensive; the - // exact-match case is normally handled in the effect). - addLog(`✓ Building "${chosen.name}" (${releaseId}) — matches your App Store app.`) - void (async () => { - try { - await passGate('fix-build-id', releaseId) - } - catch { - addLog('⚠ Could not save the verified bundle ID; you may be re-prompted next run.', 'yellow') - } - })() - return + {/* Import: distribution mode (now FIRST visible step in import flow) */} + {step === 'import-distribution-mode' && ( + { + if (value === '__cancel__') { + setImportMode(false) + // Clear the persisted import-distribution and setupMethod since + // the user is bailing to the create-new path. + const existing = await loadProgress(appId) + if (existing) { + existing.setupMethod = 'create-new' + delete existing.importDistribution + await saveProgress(appId, existing) } - setVerifyChosenApp(chosen) - setVerifyPath('fix-build-id') - }} - /> - - ) - })()} - - )} - - {/* Import: distribution mode (now FIRST visible step in import flow) */} - {step === 'import-distribution-mode' && ( - { - if (value === '__cancel__') { - setImportMode(false) - // Clear the persisted import-distribution and setupMethod since - // the user is bailing to the create-new path. - const existing = await loadProgress(appId) - if (existing) { - existing.setupMethod = 'create-new' - delete existing.importDistribution - await saveProgress(appId, existing) + setStep('api-key-instructions') + return } - setStep('api-key-instructions') - return - } - const mode = value as 'app_store' | 'ad_hoc' - setImportDistribution(mode) - // Persist so a CLI restart at any later step (incl. verifying-key - // or saving-credentials) knows we're in app_store vs ad_hoc. - // Codex caught a bug where without this, resumed sessions - // re-entered the create-new path via the stale `importMode=false` - // default — fixed here by hydrating both fields on mount. - const existing = await loadProgress(appId) || { - platform: 'ios' as const, - appId, - startedAt: new Date().toISOString(), - completedSteps: {}, - } - existing.setupMethod = 'import-existing' - existing.importDistribution = mode - await saveProgress(appId, existing) - upsertLog('✔ Distribution · ', `✔ Distribution · ${mode}`) - if (mode === 'app_store') { + const mode = value as 'app_store' | 'ad_hoc' + setImportDistribution(mode) + // Persist so a CLI restart at any later step (incl. verifying-key + // or saving-credentials) knows we're in app_store vs ad_hoc. + // Codex caught a bug where without this, resumed sessions + // re-entered the create-new path via the stale `importMode=false` + // default — fixed here by hydrating both fields on mount. + const existing = await loadProgress(appId) || { + platform: 'ios' as const, + appId, + startedAt: new Date().toISOString(), + completedSteps: {}, + } + existing.setupMethod = 'import-existing' + existing.importDistribution = mode + await saveProgress(appId, existing) + upsertLog('✔ Distribution · ', `✔ Distribution · ${mode}`) + if (mode === 'app_store') { // Need .p8 for TestFlight upload AND for any profile auto-recovery. // After verifying-key the import-mode branch routes back to import-pick-identity. // Skip the .p8 input chain entirely if the key was already @@ -3867,9 +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: +
{ - if (col !== 'Profile') - return undefined - if (val === 'AVAILABLE') - return 'green' - if (val === 'UNAVAILABLE') - return 'red' - // Spinner cell — every pending render is one of - // SPINNER_FRAMES followed by ` checking…`. Endswith is - // cheaper than scanning frame chars and tolerates the - // frame index rolling over between renders. - if (typeof val === 'string' && val.endsWith(' checking…')) - return 'yellow' - return undefined - }} - /> - - - )} - {available.length === 0 && ( - - {`✖ NO CERTIFICATES AVAILABLE`} - - All - {' '} - {unavailable.length} - {' '} - identit - {unavailable.length === 1 ? 'y is' : 'ies are'} - {' '} - unavailable on Apple's side. See the table below for the reason, or use "Create new" to generate a fresh cert + profile. - - - - )} - {unavailable.length > 0 && ( - <> - {`✖ CERTIFICATE${unavailable.length === 1 ? '' : 'S'} UNAVAILABLE (${unavailable.length})`} - -
(col === 'Reason' ? 'yellow' : undefined)} - cellDim={col => col !== 'Reason'} - /> - - - )} - Pick an option: - { - if (value === 'use-create') { - setStep('import-create-profile-only') - return - } - if (value === 'use-file') { - mobileprovisionPickerOpenedRef.current = false - setStep('import-provide-profile-path') - return - } - if (value === 'open-anyway') { - open('https://developer.apple.com/account/resources/profiles/list') - addLog('🌐 Opened Apple Developer Portal — once you have downloaded the .mobileprovision file, come back and pick "📁 Use a .mobileprovision file from disk".', 'yellow') + + {canAutoCreate && ( + + 💡 Recommended: let Capgo do this automatically. + + "✨ Create a new App Store profile for this cert via Apple" runs the same steps via the Apple API — same cert, same bundle ID, no portal navigation, no manual download. + + + + )} + + )} + + {/* Issuer ID */} + {step === 'input-issuer-id' && ( + { + const cleaned = value.trim() + if (!cleaned) + return + setIssuerId(cleaned) + upsertLog('✔ Issuer ID · ', `✔ Issuer ID · ${cleaned}`) + void savePartialProgress({ issuerId: cleaned }) + setStep('verifying-key') + }} + /> + )} + + {/* Verifying */} + {step === 'verifying-key' && } + + {/* Creating certificate */} + {step === 'creating-certificate' && } + + {/* Certificate limit — ask which to revoke */} + {step === 'cert-limit-prompt' && ( + { + const ourCertId = certData?.certificateId || initialProgress?.completedSteps.certificateCreated?.certificateId + const isOurs = ourCertId === c.id + const creator = isOurs ? ' · 🔧 Created by Capgo' : '' + return { + label: `🗑️ ${c.name} · expires ${c.expirationDate.split('T')[0]}${creator}`, + value: c.id, + } + }), + { label: '✖ Exit onboarding', value: '__exit__' }, ]} onChange={(value) => { - if (value === 'no') { - setSetupMode('declined') - setStep('ask-export-env') - return + if (value === '__exit__') { + addLog(`Exiting. Revoke a certificate manually in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') + exitOnboarding() + } + else { + setCertToRevoke(value) + setStep('revoking-certificate') + } + }} + /> + )} + + {/* Revoking certificate */} + {step === 'revoking-certificate' && } + + {/* Creating profile */} + {step === 'creating-profile' && } + + {/* Duplicate profile prompt */} + {step === 'duplicate-profile-prompt' && ( + { + if (value === 'delete') { + setStep('deleting-duplicate-profiles') + } + else { + addLog(`Exiting. Delete the duplicate profiles in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') + exitOnboarding() } - setSetupMode(value as 'with-workflow' | 'secrets-only') - setStep('checking-ci-secrets') }} /> - - )} - - {step === 'ask-export-env' && ( - - Export the credentials as a .env file instead? - - Writes - {' '} - {defaultExportPath(appId, 'ios').split('/').slice(-1)[0]} - {' '} - so you can wire up CI later via - {' '} - gh secret set -f - {' '} - or paste the values manually. - - - { - setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') + setStep(value === 'yes' ? 'checking-ci-secrets' : 'build-complete') }} /> - - )} - - {step === 'pick-package-manager' && (() => { - const detected = normalizePackageManager(pm.pm) - const detectionNote = pm.pm === 'unknown' - ? '(no recognizable lockfile in this project — pick whichever you actually use)' - : `(detected from your lockfile — ${pm.pm})` - return ( + )} + + {step === 'ask-github-actions-setup' && ( - Which package manager does this project use? + + + Set up GitHub Actions for you? - Drives the install + build steps in the generated workflow. We + Capgo can push your {' '} - {detectionNote} + {ciSecretEntries.length} + {' '} + build env var + {ciSecretEntries.length === 1 ? '' : 's'} + {' '} + as repository secrets and drop a + {' '} + .github/workflows/capgo-build.yml file you can dispatch manually. { - if (value === '__skip__') { - setBuildScriptChoice({ type: 'skip' }) - setStep('preview-workflow-file') - return - } - if (value === '__custom__') { - setStep('pick-build-script-custom') - return - } - setBuildScriptChoice({ type: 'npm-script', name: value }) - setStep('preview-workflow-file') - }} - /> - - )} - - {step === 'pick-build-script-custom' && ( - - Custom build command - - Type the exact command you want the workflow to run before - {' '} - capgo build request - {' '} - (e.g. - {' '} - make web - , - {' '} - bash scripts/build.sh - ). - - - { - const cleaned = value.trim() - if (!cleaned) + )} + + {step === 'ask-export-env' && ( + + Export the credentials as a .env file instead? + + Writes + {' '} + {defaultExportPath(appId, 'ios').split('/').slice(-1)[0]} + {' '} + so you can wire up CI later via + {' '} + gh secret set -f + {' '} + or paste the values manually. + + + { + setStep(value === 'replace' ? 'overwrite-and-export-env' : 'build-complete') + }} + /> + + )} + + {step === 'pick-package-manager' && (() => { + const detected = normalizePackageManager(pm.pm) + const detectionNote = pm.pm === 'unknown' + ? '(no recognizable lockfile in this project — pick whichever you actually use)' + : `(detected from your lockfile — ${pm.pm})` + return ( + + Which package manager does this project use? + + Drives the install + build steps in the generated workflow. We + {' '} + {detectionNote} + + { + if (value === '__skip__') { + setBuildScriptChoice({ type: 'skip' }) + setStep('preview-workflow-file') + return + } + if (value === '__custom__') { + setStep('pick-build-script-custom') + return + } + setBuildScriptChoice({ type: 'npm-script', name: value }) + setStep('preview-workflow-file') + }} + /> + + )} + + {step === 'pick-build-script-custom' && ( + + Custom build command + + Type the exact command you want the workflow to run before + {' '} + capgo build request + {' '} + (e.g. + {' '} + make web + , + {' '} + bash scripts/build.sh + ). + + + { + const cleaned = value.trim() + if (!cleaned) return - } - trackWorkflowEvent('workflow-preview-action', { decision: value === 'write' ? 'write' : 'cancel' }) - setPreviewDiff([]) - setStep(value === 'write' ? 'writing-workflow-file' : 'build-complete') + setBuildScriptChoice({ type: 'custom', command: cleaned }) + setStep('preview-workflow-file') }} /> - ) - })()} - {step === 'preview-workflow-file' && previewDiff.length === 0 && ( - - - - )} - - {/* view-workflow-diff renders as a fullscreen early-return takeover above. */} - - {step === 'writing-workflow-file' && ( - - - - )} - - {step === 'checking-ci-secrets' && ( - - - - )} - - {step === 'confirm-secrets-push' && ( - (() => { - const existingSet = new Set(ciSecretExistingKeys) - const newCount = ciSecretEntries.filter(entry => !existingSet.has(entry.key)).length - const replaceCount = ciSecretEntries.length - newCount + )} + + {step === 'preview-workflow-file' && previewDiff.length > 0 && (() => { + const allEqual = previewDiff.every(l => l.kind === 'eq') + const writeLabel = allEqual + ? '✏️ Write file anyway (re-writes identical content)' + : (previewIsNew ? '✏️ Write file' : '✏️ Replace existing file') + const skipLabel = '❌ Do not write file' + const title = previewIsNew + ? `🆕 Proposed new file — ${previewExistingPath ?? WORKFLOW_PATH}` + : `✏️ Proposed changes — ${previewExistingPath ?? WORKFLOW_PATH}` + const subtitle = previewIsNew + ? 'Nothing exists on disk yet. Every line below is what would be written.' + : 'Proposed diff vs the file on disk. Lines marked - would be removed, lines marked + would be added.' return ( - ⚠ Confirm before pushing secrets - - Repository: - {' '} - {ciSecretRepoLabel} - {' '} - (resolved via `gh repo view`) - - - {`Will push ${ciSecretEntries.length} env var${ciSecretEntries.length === 1 ? '' : 's'}`} - {replaceCount > 0 ? ` — ${newCount} new, ${replaceCount} REPLACING existing:` : ' — all new:'} - - - ({ - name: entry.key, - status: existingSet.has(entry.key) ? 'REPLACE' : 'NEW', - }))} + + + + What should we do with + {WORKFLOW_PATH} + ? + + { + setStep(value === 'confirm' ? 'uploading-ci-secrets' : 'build-complete') + }} + /> - ) - })() - )} - {step === 'confirm-secrets-push' && ( - <> - - { - if (value === 'view') { - let lines: string[] = [] - try { lines = readFileSync(supportLogPathRef.current, 'utf8').split('\n') } - catch { lines = ['(could not read the logs file)'] } - setSupportLogLines(lines) - setStep('support-log-view') - return + {step === 'support-confirm' && ( + + Email Capgo support + {supportConfirmMessage} + + +) + +// ── p8-create-method-select ─────────────────────────────────────────────────── +// macOS only: hand-create the key at App Store Connect, or let the guided helper +// drive the whole flow in an embedded browser and capture the key automatically. +export interface P8CreateMethodSelectStepProps { + dense?: boolean + onChange: (value: string) => void | Promise +} + +export const P8CreateMethodSelectStep: FC = ({ dense = false, onChange }) => ( + + + How do you want to create the .p8 key? + + {!dense && } +