fix(cli/build init): route to recovery when no profiles match this app#2308
fix(cli/build init): route to recovery when no profiles match this app#2308WcaleNieWolny wants to merge 30 commits into
Conversation
Two UX fixes for the iOS import-existing onboarding flow: 1. The provisioning-profile picker filters profiles by appId + distribution mode. When the filter removed all profiles, the user was dropped at an empty picker with only "Back" — a dead end. Now the filter runs early (at identity selection AND defensively when entering pick-profile from any source) and routes to import-no-match-recovery instead, where the user can fetch / create the profile via the Apple API with their .p8 key, or open the developer portal manually. The recovery alert wording now distinguishes "no profiles at all for this identity" from "profiles exist but none match this app's bundle ID / distribution mode" so the user knows what's actually wrong. 2. The "✔ Distribution · app_store" log line could appear multiple times in the output. addLog now dedupes consecutive identical entries, which covers the immediate symptom plus any other repeated entries from resume / back navigation. The bundleId + distribution filter is extracted as filterProfilesForApp in macos-signing.ts (used in 3 places in app.tsx), with unit tests covering matching, mismatched bundleId, mismatched distribution, null/undefined distribution, and empty input.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds filterProfilesForApp (filters provisioning profiles by bundleId and optionally by distribution) with unit tests; integrates it into the macOS import UI to populate profile pickers, guard routing to recovery when no usable profiles exist, expand recovery messaging and actions, deduplicate consecutive log entries, and add an Apple cert checking onboarding step. ChangesProfile Filtering Helper and macOS Import Flow
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
…empty-recovery # Conflicts: # cli/src/build/onboarding/ui/app.tsx
When the user picks "Fetch matching profile from Apple now" or "Create a
new App Store profile via Apple" from import-no-match-recovery, and
findCertIdBySha1 returns null (Apple's /certificates response doesn't
include a SHA1 match for the local Keychain cert), the handler used to
throw an error like:
Apple does not have a certificate matching the Keychain identity
"Apple Distribution: digital shift oü (UVTJ336J2D)". Either it was
revoked on Apple's side or it was never uploaded. Use "Create new"
instead.
handleError caught it and dumped the user at the generic support-bundle
error screen, whose only options ("Retry" / "Restart onboarding") are
useless here — Retry runs the same failing call again, and the user
shouldn't have to nuke their progress to escape.
Both call sites (import-fetching-profile and import-create-profile-only)
now do what the sibling "Apple has the cert but no profiles linked"
branch already does: addLog a yellow warning and setStep back to
import-no-match-recovery so the user can pick a different recovery path
(Open Developer Portal, Back to identity selection, Exit) without
restarting.
Also softened the wording — the error message claimed Apple revoked the
cert or never had it, but a frequent real cause is that
listDistributionCerts only filters for the legacy IOS_DISTRIBUTION type
and excludes newer cross-platform DISTRIBUTION certs (shown as "Apple
Distribution:" in Keychain). A follow-up commit will broaden that
filter; this commit just stops the dead-end so users aren't stuck while
the deeper fix lands.
…ery menu
The no-match-recovery menu used to offer "Fetch matching profile from
Apple" and "Create a new App Store profile via Apple" unconditionally,
even when Apple's API can't find the chosen Keychain cert at all — in
which case both options fail with the same error and the user is stuck
clicking actions that can never succeed.
Now, after the user picks an identity with no matching local profile
AND we have a verified ASC API key, the flow lands on a new step
`import-checking-apple-cert` (brief spinner: "Checking with Apple for
<identity>…") which runs `findCertIdBySha1` once and stores the result
in `appleCertIdForChosen` state. The recovery menu then curates its
options from that result:
• Apple has the cert (string) → show Fetch + Create + Open Portal +
Back. (Most users, especially once the lookup filter is broadened
in a follow-up.)
• Apple lacks the cert (null) → hide Fetch + Create (they can't
succeed). Surface a new "Switch to Create new" option that exits
the import flow, reuses the already-verified .p8, and routes to
`creating-certificate` to generate a fresh cert + profile via
Apple. No Keychain side effects — the orphan local cert stays put.
• Not checked yet (undefined) → ad_hoc without ASC key. Falls back
to the legacy "Provide ASC API key, then …" labels (which route
through api-key-instructions before retrying the action), so we
don't pessimistically demand a key from ad_hoc users.
The alert + body copy also branch on the pre-check result so users see
why the API-driven options aren't shown when the cert isn't on Apple.
Identity selection resets `appleCertIdForChosen` so a re-pick triggers
a fresh check.
`import-pick-profile`'s defense-in-depth guard (back-navigation /
resume / Apple-fetch with no matches) now routes through the same
pre-check, so every entry point into recovery goes through the same
gate. If a previous check is already cached for this identity, the
gate skips the re-query to avoid a redundant round-trip.
No change to the failing branches in `import-fetching-profile` /
`import-create-profile-only` themselves — they remain as defensive
guards (the menu just doesn't offer them when they'd fail).
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cli/src/build/onboarding/ui/app.tsx`:
- Around line 197-211: The apple-cert tri-state (appleCertIdForChosen) must be
authoritative for all recovery flows: ensure any path that performs an ASC
lookup (including the "provide ASC API key, then fetch/create" flow) either
routes through the existing import-checking-apple-cert step or writes the lookup
result into appleCertIdForChosen via setAppleCertIdForChosen before navigating
back to the recovery UI; update the handlers that trigger Apple lookups so they
persist the returned string/null into appleCertIdForChosen (and reset to
undefined only when the chosen identity truly changes) — apply the same change
to the other occurrence referenced around the alternate block.
- Around line 1717-1744: The UI is offering the "switch-create-new" flow for
imports with importMode === 'ad_hoc', which improperly alters distribution;
update the option logic so switchToCreateNewOption (and any branch that pushes
the 'switch-create-new' value) is only added when importMode !== 'ad_hoc'.
Locate the switchToCreateNewOption definition and the other branches flagged
(around the fetch/create option blocks and the other ranges that reference
'switch-create-new') and add a guard using importMode (or skip when importMode
=== 'ad_hoc') so the 'switch-create-new' choice is never presented for ad_hoc
imports. Ensure labels/values for fetch/create remain unchanged for ad_hoc.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: b8476d2e-7c8a-4bfa-b462-49581a029e68
📒 Files selected for processing (2)
cli/src/build/onboarding/types.tscli/src/build/onboarding/ui/app.tsx
…ths)
Two related UX fixes for users who got stuck in the create-new path
after clicking a button that looked like simple navigation:
1. Rename the destructive "Cancel and use Create new instead" buttons
(in import-distribution-mode picker and import-pick-identity
picker) to "Switch to 'Create new' (Apple generates a fresh cert
+ profile)". Both old buttons silently committed
setupMethod=create-new to the on-disk progress file, then routed to
api-key-instructions — which on Ctrl+C+rerun put the user into
creating-certificate directly, and if their Apple team was at the
3-cert limit, into cert-limit-prompt with no obvious way out. The
old label read like navigation; the behaviour was a permanent state
mutation. New label says what actually happens.
2. Add "Switch back to Import existing" escape options at the two
places users land when stuck in this trap:
- cert-limit-prompt: alongside the "Revoke an existing cert" rows,
offer "Switch back to Import existing (use a cert from your
Keychain)". Reverses setupMethod=create-new in progress and routes
to import-scanning. Targets the exact symptom the user hit:
resumed into creating-certificate, hit the cert limit, no way
back to the import flow that would have just worked with their
existing Keychain cert.
- api-key-instructions: when the user is not currently in import
mode AND we're on macOS, append a "Switch to Import existing
(use a cert from your Keychain instead)" option to the file-input
Select. Same persistence semantics as the cert-limit-prompt
escape. Hidden on non-macOS hosts because the import flow needs
Keychain access.
The underlying root cause (the lookup filter that makes the cert
appear "not on Apple") will be fixed in a follow-up commit on the
same PR. These two changes just stop the user being stuck while we
land that.
listDistributionCerts was filtering only IOS_DISTRIBUTION, the legacy
iOS-specific cert type. Apple deprecated it around 2021 in favor of the
newer cross-platform DISTRIBUTION type — and new certs created from
App Store Connect default to DISTRIBUTION. macOS reports these in
Keychain as "Apple Distribution: <team>" (vs. the old "iPhone
Distribution: <team>" for IOS_DISTRIBUTION).
A team's cert ledger almost always has both types after a few years.
The old filter silently excluded the newer ones, causing
findCertIdBySha1 to return null for any local "Apple Distribution:"
identity — which surfaced as the misleading "Apple does not have a
certificate matching the Keychain identity" error in the no-match
recovery flow, even though the cert was right there in the Developer
Portal.
Concrete fix:
- Add DISTRIBUTION to the filter (comma-separated; Apple's API
accepts the multi-value syntax).
- Bump limit from 10 to 200 (Apple's documented max). Avoids any
pagination concerns for teams with deep cert histories — the per-
team active-cert limit is far lower than 200, but expired and
revoked rows also count toward this endpoint's pagination.
- Comment notes that DISTRIBUTION_MANAGED is intentionally excluded
because those certs are Apple-HSM-signed (Xcode Cloud / managed
signing) and can't be used to sign builds on third-party CI. They
will still surface in the upcoming Available/Unavailable table view
(Phase 2) marked as unsignable.
For your specific case ("Apple Distribution: digital shift oü
(UVTJ336J2D)"), this is the actual cure: findCertIdBySha1 will now
find your cert, the no-match-recovery flow will offer Fetch + Create
(not Switch-to-Create-new), and the existing happy path will work
end-to-end.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
cli/src/build/onboarding/ui/app.tsx (3)
1558-1565:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse the filtered profile count in the identity picker.
m.profiles.lengthstill counts profiles for other bundle IDs and distributions, so this row can say "matching profiles" and then immediately route to no-match recovery after selection. Derive the count fromfilterProfilesForApp(...)instead.Suggested fix
...importMatches.map((m) => { - const matchCount = m.profiles.length + const matchCount = filterProfilesForApp(m.profiles, appId, 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 } }),🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/build/onboarding/ui/app.tsx` around lines 1558 - 1565, The picker label currently uses m.profiles.length which counts all profiles for an identity; change it to compute the count via filterProfilesForApp(...) so only profiles matching the current app and distribution are counted. Replace uses of m.profiles.length in the importMatches.map label construction with the filteredProfiles.length (e.g., const filtered = filterProfilesForApp(m.profiles, bundleId, distribution); const matchCount = filtered.length) and keep the rest of the label logic the same, leaving the returned value as m.identity.sha1.
2234-2266:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHide the Import-existing recovery path off macOS.
cert-limit-promptis reachable on Linux/Windows too, but this option always routes toimport-scanning, which depends on the macOS Keychain flow. On non-macOS hosts it offers a dead-end recovery path.Suggested fix
options={[ ...existingCerts.map((c) => { const ourCertId = certData?.certificateId || initialProgress?.completedSteps.certificateCreated?.certificateId const isOurs = ourCertId === c.id const creator = isOurs ? ' · 🔧 Created by Capgo' : '' return { label: `🗑️ Revoke ${c.name} · expires ${c.expirationDate.split('T')[0]}${creator}`, value: c.id, } }), - { label: '🔄 Switch back to Import existing (use a cert from your Keychain)', value: '__switch-import__' }, + ...(isMacOS() + ? [{ label: '🔄 Switch back to Import existing (use a cert from your Keychain)', value: '__switch-import__' }] + : []), { label: '✖ Exit onboarding', value: '__exit__' }, ]} onChange={async (value) => { if (value === '__exit__') { addLog(`Exiting. Revoke a certificate manually in App Store Connect, then resume with ${buildInitCommand}.`, 'yellow') exitOnboarding() return } - if (value === '__switch-import__') { + if (value === '__switch-import__' && isMacOS()) { // Reverse the destructive setupMethod=create-new commit that // happens when the user clicks "Switch to Create new" from // the import flow. Lets users back out of the create-new🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/build/onboarding/ui/app.tsx` around lines 2234 - 2266, The "Switch back to Import existing" option and its handler should be gated to macOS so non-mac hosts don't get routed to the dead-end import-scanning flow: update the options array creation to include the { label: '🔄 Switch back to Import existing ...', value: '__switch-import__' } item only when running on macOS (e.g. process.platform === 'darwin' or an isMac flag), and in the onChange branch for value === '__switch-import__' add a platform guard that logs a friendly message and aborts if not macOS; keep the existing loadProgress, saveProgress, setImportMode, setStep and addLog calls for the allowed macOS path.
1565-1579:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon't offer
Switch to "Create new"afterad_hocis selected.This branch jumps into
creating-certificate, which only creates App Store credentials in this flow. IfimportDistribution === 'ad_hoc', selecting it silently changes the requested distribution.Suggested fix
- { label: '🆕 Switch to "Create new" (Apple generates a fresh cert + profile)', value: '__cancel__' }, + ...(importDistribution !== 'ad_hoc' + ? [{ label: '🆕 Switch to "Create new" (Apple generates a fresh cert + profile)', value: '__cancel__' }] + : []), ]} onChange={async (value) => { - if (value === '__cancel__') { + if (value === '__cancel__' && importDistribution !== 'ad_hoc') { setImportMode(false) // Persist the switch so a CLI restart doesn't resume into // the import flow the user just abandoned. Mirrors the same🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/build/onboarding/ui/app.tsx` around lines 1565 - 1579, The "Switch to 'Create new'" branch currently runs when users previously chose importDistribution === 'ad_hoc', which silently converts their request to App Store flow; guard this by checking importDistribution before offering or applying the switch: either remove the option from the choices when importDistribution === 'ad_hoc' or, in the onChange handler for value '__cancel__', abort the transition if the loaded progress (from loadProgress(appId)) has existing.importDistribution === 'ad_hoc' — do not call setImportMode(false), do not set existing.setupMethod = 'create-new', do not delete existing.importDistribution, and do not setStep('api-key-instructions') in that case; use the existing symbols setImportMode, loadProgress, saveProgress, setStep and existing.importDistribution to implement the guard.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@cli/src/build/onboarding/ui/app.tsx`:
- Around line 1558-1565: The picker label currently uses m.profiles.length which
counts all profiles for an identity; change it to compute the count via
filterProfilesForApp(...) so only profiles matching the current app and
distribution are counted. Replace uses of m.profiles.length in the
importMatches.map label construction with the filteredProfiles.length (e.g.,
const filtered = filterProfilesForApp(m.profiles, bundleId, distribution); const
matchCount = filtered.length) and keep the rest of the label logic the same,
leaving the returned value as m.identity.sha1.
- Around line 2234-2266: The "Switch back to Import existing" option and its
handler should be gated to macOS so non-mac hosts don't get routed to the
dead-end import-scanning flow: update the options array creation to include the
{ label: '🔄 Switch back to Import existing ...', value: '__switch-import__' }
item only when running on macOS (e.g. process.platform === 'darwin' or an isMac
flag), and in the onChange branch for value === '__switch-import__' add a
platform guard that logs a friendly message and aborts if not macOS; keep the
existing loadProgress, saveProgress, setImportMode, setStep and addLog calls for
the allowed macOS path.
- Around line 1565-1579: The "Switch to 'Create new'" branch currently runs when
users previously chose importDistribution === 'ad_hoc', which silently converts
their request to App Store flow; guard this by checking importDistribution
before offering or applying the switch: either remove the option from the
choices when importDistribution === 'ad_hoc' or, in the onChange handler for
value '__cancel__', abort the transition if the loaded progress (from
loadProgress(appId)) has existing.importDistribution === 'ad_hoc' — do not call
setImportMode(false), do not set existing.setupMethod = 'create-new', do not
delete existing.importDistribution, and do not setStep('api-key-instructions')
in that case; use the existing symbols setImportMode, loadProgress,
saveProgress, setStep and existing.importDistribution to implement the guard.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 538011dd-343a-45e0-83bf-e602fc68756c
📒 Files selected for processing (2)
cli/src/build/onboarding/apple-api.tscli/src/build/onboarding/ui/app.tsx
When verifying-key failed (e.g. user typed the Issuer UUID into the Key ID field by mistake, or vice versa — common given they're labelled similarly in App Store Connect), the error screen offered only: 🔄 Try again ← runs verifying-key with same bad values ↩️ Restart onboarding ← nukes ALL progress, retype everything ❌ Exit "Try again" was useless because nothing changed. "Restart" was overkill — the user just wanted to fix a typo, not re-pick their .p8, distribution mode, etc. Three changes: 1. FilteredTextInput now accepts an `initialValue` prop. The input pre-fills with this value, backspace works to delete from it, and submit returns the (possibly edited) string. Backwards compatible — default is empty string, existing call sites unchanged. 2. The input-key-id and input-issuer-id steps pass the current `keyId` / `issuerId` state as initialValue, so re-entering those steps shows the value the user already typed (or had auto-detected from the .p8 filename) ready to edit, not a blank field. 3. The error screen's Select now includes two explicit edit options — `✏️ Edit Key ID (currently: <value>)` and `✏️ Edit Issuer ID (currently: <value>)` — but ONLY when the failed step was `verifying-key`. Picking either clears the error and routes straight to the relevant input step with the value pre-filled. No .p8 re-selection, no UUID retype. Now the typical typo-fix workflow takes one keypress (down-arrow to "Edit Issuer ID") + backspaces to fix the value + Enter, instead of abandoning the whole session.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
cli/src/build/onboarding/ui/app.tsx (1)
655-659: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueMissing
cancelledcheck beforesetStepin async IIFE.Other async blocks in this
useEffectcheckif (cancelled) returnafter async operations. This block callsawait loadProgress(appId)but doesn't guard the subsequentsetStepagainst cleanup.Consistency fix
else { ;(async () => { const apiKeyAvailable = !!(p8ContentRef.current || (await loadProgress(appId))?.completedSteps?.apiKeyVerified) + if (cancelled) + return setStep(apiKeyAvailable ? 'import-checking-apple-cert' : 'import-no-match-recovery') })() }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cli/src/build/onboarding/ui/app.tsx` around lines 655 - 659, The async IIFE sets state after awaiting loadProgress without checking for component cleanup; update the IIFE that uses p8ContentRef and loadProgress(appId) so it returns early if the effect has been cancelled (check the existing cancelled flag) before calling setStep with 'import-checking-apple-cert' or 'import-no-match-recovery'; ensure you mirror the same "if (cancelled) return" pattern used in other async blocks in this useEffect to avoid setting state on an unmounted component.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@cli/src/build/onboarding/ui/app.tsx`:
- Around line 655-659: The async IIFE sets state after awaiting loadProgress
without checking for component cleanup; update the IIFE that uses p8ContentRef
and loadProgress(appId) so it returns early if the effect has been cancelled
(check the existing cancelled flag) before calling setStep with
'import-checking-apple-cert' or 'import-no-match-recovery'; ensure you mirror
the same "if (cancelled) return" pattern used in other async blocks in this
useEffect to avoid setting state on an unmounted component.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: fd9eca78-93db-4627-b36c-46f94e12b7af
📒 Files selected for processing (2)
cli/src/build/onboarding/ui/app.tsxcli/src/build/onboarding/ui/components.tsx
…very Third edit option alongside "Edit Key ID" and "Edit Issuer ID" for the case where the wrong .p8 file was selected entirely (revoked key, file from wrong Apple account, picked an outdated AuthKey_*.p8, etc.). The shipped error screen offered only the two ID-edit paths, which left the user stuck if the actual mistake was the key file itself. Routes to api-key-instructions (the same step that hosts the file picker / manual-path Select), with pickerOpenedRef reset so the macOS file picker can re-open from a clean state. After a new .p8 is picked, the auto-detection in p8-method-select repopulates Key ID from the filename pattern; Issuer ID stays unchanged because it's account- scoped, not key-scoped. Label shows the basename of the current path (currently: foo.p8) so the user can confirm what's about to be replaced.
Apple Key IDs are always exactly 10 alphanumeric chars uppercase (e.g. "KDTXMK292V"). Issuer IDs are standard UUIDs — hex + hyphens, 36 chars. Both have rigid formats, but the input fields accepted arbitrary text, which is how users end up pasting "0cd4db4a-5598-45b8-9d32-75cdf127d005" into the Key ID field — verifying-key fails 1-2s later with no indication that the input was structurally invalid. FilteredTextInput now supports three new optional props: - `allowedPattern: RegExp` — per-character whitelist. Anything that doesn't match is dropped on insert. Lets fields with tight formats refuse invalid keystrokes / pastes at the input level. - `maxLength: number` — hard cap on buffer length, silently truncates past-cap characters. Paste-safe: pasting "Key ID: KDTXMK292V foo" truncates to "KDTXMK292V" after filter strips the spaces, colon, and trailing word. - `transform: (s) => s` — post-filter transform on the full buffer. Used to force uppercase for Apple Key IDs (case-insensitive on Apple's side but uppercase by convention; auto-uppercasing prevents a class of "I typed lowercase, why doesn't it work" tickets). Pipeline order: blacklist filter → allowedPattern whitelist → maxLength truncate → transform. Pulled out as a pure `applyConstraints` helper so initialValue prefill goes through the same path as user keystrokes (otherwise an initialValue with invalid chars would appear briefly before being filtered). Visual: when maxLength is set and the field isn't masked, append a dim "n/max" counter to the right of the cursor so the user sees how many characters they have left. Applied to both Key ID input variants (auto-detected-from-filename and manual) with `allowedPattern=/[a-zA-Z0-9]/`, `maxLength=10`, `transform=toUpperCase`. Updated the placeholder to "KDTXMK292V" so the example shows the correct format. Applied to Issuer ID input with `allowedPattern=/[a-fA-F0-9-]/`, `maxLength=36`. No transform — UUIDs are case-insensitive on Apple's side, and lowercase is the more common copy-paste form. Net effect: pasting the issuer UUID into the Key ID field now truncates to the first 10 valid alphanumeric chars (the hyphens and characters past position 10 are silently dropped), so the typo we just saw — both fields reading the same UUID — becomes impossible.
Editing a field surfaced both the old AND the new value in the log:
✔ Key file selected · /path/to/AuthKey_66FGQZB566.p8
✔ Key ID · 0cd4db4a-5598-45b8-9d32-75cdf127d005 ← original typo
✔ Issuer ID · 0cd4db4a-5598-45b8-9d32-75cdf127d005
✔ Key ID · 66FGQZB566 ← after edit
✔ Issuer ID · 0cd4db4a-5598-45b8-9d32-75cdf127d005 ← re-confirmed (same)
The "audit trail" of every keystroke is noise — the user wants to see
the current state, not the history.
New `upsertLog(prefix, text, color)` helper alongside `addLog`. Finds
the first existing log entry whose text starts with `prefix` and
rewrites it in place; otherwise appends. Used for field-update events
that the user can re-enter mid-session:
- "✔ Key file selected · …" / "✔ Key file found · …" (prefix: "✔ Key file")
- "✔ Key ID · …" (prefix: "✔ Key ID · ")
- "✔ Issuer ID · …" (prefix: "✔ Issuer ID · ")
- "✔ Distribution · …" (prefix: "✔ Distribution · ")
- "✔ Identity · …" (prefix: "✔ Identity · ")
- "✔ Profile · …" (prefix: "✔ Profile · ")
The Key file prefix is intentionally short ("✔ Key file") so a switch
between the file-picker variant ("Key file selected") and the manual-
path variant ("Key file found") upserts the same row instead of
stacking them.
Pure `addLog` is preserved for one-shot events that don't have a
field semantics — "API Key verified", "Distribution certificate
created", "Provisioning profile created", error toasts, etc.
Net effect after editing the user's reported typo:
✔ Key file selected · /path/to/AuthKey_66FGQZB566.p8
✔ Key ID · 66FGQZB566 ← only the current value
✔ Issuer ID · <uuid> ← only the current value
✔ API Key verified — Key: 66FGQZB566
…dation
The single-Select identity picker mixed valid and Apple-unrecognized
certs into one list, then surfaced "this cert isn't on Apple" only
after the user clicked an Apple-API-dependent recovery option. Three
changes wire this up properly so users only see options that can
succeed:
1) **Classifier helper** (`apple-api.ts` + tests):
`classifyCertAvailability({ localExpirationDate?, isManaged?,
appleCertId, lookupError? })` returns
`{ available, reason, reasonText, appleCertId? }` with stable
reason codes: expired, managed, not-visible, check-failed,
no-private-key. Local-side disqualifiers (expired date, managed
type) short-circuit before consulting the lookup so we don't burn
API calls on certs we already know can't sign. Returns neutral
wording for null lookup results — does NOT claim revocation we
can't prove from the response. 7 unit tests cover every branch
including malformed-date tolerance and non-Error throws.
2) **Eager batch validation** (new step `import-validating-all-certs`):
After verifying-key succeeds in the import flow, a fan-out spinner
step runs `findCertIdBySha1` in parallel across every scanned
Keychain identity (typically 1-3, capped to whatever the user has).
Each lookup is wrapped so a network blip on one cert doesn't
disqualify the others. Results feed `classifyCertAvailability`
into an `identityAvailability` Record keyed by SHA1, then the
picker step renders. Skipped for ad_hoc users without an ASC API
key — they fall through to the single-list layout as before.
3) **Two-table picker UI** (`import-pick-identity` rewrite):
```
✅ AVAILABLE (1)
────────────────────────────────────────────────────────────
NAME TEAM PROFILES (matching/total)
────────────────────────────────────────────────────────────
▶ 🔑 Apple Distribution: digital shift oü UVTJ336J2D 2/5 ✓
[🆕 Switch to "Create new" ...]
[✖ Exit onboarding]
⚠️ UNAVAILABLE (2)
────────────────────────────────────────────────────────────
NAME TEAM REASON
────────────────────────────────────────────────────────────
🔒 Apple Distribution: revoked cert UVTJ336J2D Not visible to current API key (revoked, different team, or lookup limitation)
🔒 Distribution Managed UVTJ336J2D Apple-managed — can't sign locally
💡 Unavailable certificates can't be used to sign builds. Even
downloading them from the Apple Developer Portal won't help —
the private key was only on the Mac that generated the original
CSR. Use "Create new" above to generate a fresh cert + profile
that Apple recognizes.
```
Only AVAILABLE rows are inside the `<Select>` (keyboard cursor
skips Unavailable rows entirely). UNAVAILABLE renders as a
display-only Box block below the picker, with the user-supplied
footnote about why downloading from the portal won't fix things
(the private key never left the originating Mac).
Column widths fixed for ~80-col terminals; long names truncate
with `…`. The PROFILES column shows `matching/total` so users
with one cert linked to many apps can see at a glance which has a
ready-to-use local profile.
Cached `appleCertId` from batch validation flows through to
`appleCertIdForChosen` on identity selection, so the downstream
per-identity pre-check (when routing to no-match-recovery) skips
the redundant network round-trip.
The previous Phase 1 filter broadening + Phase 0 trap-escape commits
already made this dead-end unreachable for users with cross-platform
"Apple Distribution" certs. The table is the structural fix: even if
some future cert type slips past the filter, the picker still won't
offer impossible options because each cert's classification is
checked up-front, not on-demand.
The hand-rolled column alignment in commit fe0fa89 broke when names hit the truncation boundary — long cert names collapsed against the neighbouring Team column with no visual separator, e.g.: ❯ 🔑 Apple Distribution: digital shift oü (UV…UVTJ336J2D 0/1 I tried `ink-table` first (the obvious package) but it ships as CommonJS while modern Ink is ESM with top-level await — `require('ink')` fails the bundler. Rather than fight the dep, I wrote a small inline Table component (~75 LOC in components.tsx). The new `<Table>`: - Auto-sizes each column to the widest cell (header or any row value), capped at `maxColumnWidth` (default 50). - Truncates overflowing cells with `…` using `Array.from()` for codepoint-safe length (emoji and combining marks don't double-count). - Renders box-drawing borders (`┌┬┐ ├┼┤ └┴┘ │ ─`) so columns are visually separated whether or not cells truncate. - Optional `cellColor` / `cellDim` callbacks for per-cell styling. - Derives the column list from the first row's keys, so callers pass plain `Record<string, string>[]` data. Available + Unavailable tables in `import-pick-identity` now use it: ✅ AVAILABLE (1) ┌─────┬────────────────────────────────────────────────┬────────────┬─────────────┐ │ # │ Name │ Team │ Profiles │ ├─────┼────────────────────────────────────────────────┼────────────┼─────────────┤ │ 1 │ 🔑 Apple Distribution: digital shift oü (UVT… │ UVTJ336J2D │ 2/5 ✓ │ └─────┴────────────────────────────────────────────────┴────────────┴─────────────┘ Pick an option: ❯ [1] Apple Distribution: digital shift oü · UVTJ336J2D 🆕 Switch to "Create new" (...) ✖ Exit onboarding⚠️ UNAVAILABLE (2) ┌────────────────────────────────────────────┬────────────┬─────────────────────────────┐ │ Name │ Team │ Reason │ ├────────────────────────────────────────────┼────────────┼─────────────────────────────┤ │ 🔒 Apple Distribution: revoked cert │ UVTJ336J2D │ Not visible to current API… │ │ 🔒 Distribution Managed │ UVTJ336J2D │ Apple-managed — can't sign… │ └────────────────────────────────────────────┴────────────┴─────────────────────────────┘ The picker has a separate Select below the Available table with labelled rows (`[1] …`, `[2] …`) so users keyboard-navigate the actions while the table provides the visual context. This is the lightest- weight way to get selectable+tabular UX without a custom Ink renderer. `cellColor` is wired up for the unavailable table's Reason column (yellow) and the available table's Profiles column when a matching profile is found (green ✓), to make scan-time visual cues match the prose footnote.
…table
The Table component computed widths from `Array.from(s).length` —
codepoint count. That breaks on:
- 🔑 and other emoji: render as 2 terminal columns, count as 1 codepoint
- CJK characters: 2 columns / 1 codepoint
- Some combining marks: 0 columns / 1 codepoint
Result: rows containing emoji rendered 1 column wider per emoji than
the header border, so the closing │ visibly drifted right of the
top/bottom ┬/┴ separators (visible in the user's screenshot: the data
row's │ after "(UVTJ336J2…" sat ~1 column past the header's ┬).
Switch to `string-width` (already in our tree transitively via Ink,
now a direct dep) which returns true terminal-column width. Two pure
helpers:
- `truncateByDisplayWidth(s, max)` — accumulates per-char width via
string-width, stops before the ellipsis would overflow.
- `padByDisplayWidth(s, width)` — adds trailing spaces until the
rendered width matches the column.
Both `widths[col]` computation and per-cell rendering go through the
display-width helpers, so emoji + accented characters now align with
the border. Verified locally — `🔑 Apple Distribution…` row now ends
exactly where the header ┬ separator sits.
string-width@8.x is ESM (compatible with our Ink 5.x ESM stack).
The PiP tutorial (precompile Swift helper + predownload video + SHA1-verified playback + 5s budget + YouTube fallback) is gone — too much infrastructure for a feature that the actual tutorial video doesn't yet exist for, and the related Capgo PR #2329 would have to land + secrets provisioned before any of it could even work. Replaced with a much simpler "make the manual path less attractive" nudge: when the user picks "🌐 Open Apple Developer Portal" from the recovery menu, we no longer fire-and-forget into the portal. We route to a new `import-portal-explanation` step that: - Lists the SIX manual steps the user would need to do in the portal (sign in to the right team, create app_store profile, pick the right App ID, tick the right cert in the allowed list, download the .mobileprovision, get it back into the CLI). - When the cert is available on Apple's side AND distribution is app_store: leads with a green "💡 Recommended: let Capgo do this for you" nudge — explains that "✨ Create a new App Store profile for this cert via Apple" does all six steps automatically via the Apple API. - When that automatic path isn't available (ad_hoc, currently unsupported by createProfile): explains the constraint and points at the file-from-disk path instead. - Three actions: ✨ Use "Create a new App Store profile" instead (recommended) 🌐 Open the portal anyway (advanced) 📁 I already have a .mobileprovision on disk — let me pick it ↩️ Back to recovery menu The "Open anyway" path still opens the developer.apple.com URL in the browser and routes back to the recovery menu so the user can pick "🔄 Rescan Apple API" when ready — same final UX as before, just behind a deliberate confirmation that reads the room. Removed: - cli/src/build/onboarding/pip-tutorial.ts (entire file) - PipTamperError / precompilePipHelper / predownloadVideo / verifyAndPlayPip imports - pipHelperPromiseRef / pipVideoPromiseRef + their useRef declarations - PIP_VIDEO_URL / PIP_YOUTUBE_FALLBACK constants - ensurePipTasksStarted helper + its two call sites (setup-method- select onChange import branch, mount-time hydration) - The 70-line PiP/YouTube race block inside the Open Portal handler Net: −336 LOC (pip-tutorial.ts) − ~70 LOC (PiP wiring) + ~140 LOC (explainer step) = −266 LOC. Cap-go/capgo PR #2329 (the /private/config/builder tutorialVideo endpoint) is now orphaned — the CLI no longer consumes it. Leaving that PR open for the user to close manually since they may want the plumbing for a future revival.
When the user picks "🌐 Open the portal anyway" via the explainer step
and lands in the manual walkthrough, step 4 ("tick the cert that
matches") was vague — "pick the one with the right expiry / created-by"
without saying WHICH expiry, or "created-by what".
Apple's /v1/certificates API doesn't expose the portal's "Created by"
column (that's portal-internal), but it does return `expirationDate`
and `serialNumber`. The eager batch validation
(import-validating-all-certs) already calls /v1/certificates per
identity to look up the SHA1 — we were throwing away the rest of the
record. Now we capture it and surface it in the walkthrough.
Changes:
- New `findCertBySha1(token, sha1) → AscDistributionCert | null` in
apple-api.ts that returns the full record (id + name +
expirationDate + serialNumber). `findCertIdBySha1` still exists,
delegated to it for callers that only need the id.
- `EnrichedIdentityAvailability` extended with three optional
fields: `appleCertName`, `appleCertExpirationDate`,
`appleCertSerialNumber`.
- The eager batch step (`import-validating-all-certs`) switched
from `findCertIdBySha1` to `findCertBySha1` and stores the
metadata alongside `appleCertId` when classification yields
`available: true`. Same number of API calls per identity (the
underlying listDistributionCerts call already returned this data).
- Step 4 of the manual walkthrough now reads:
4. In the "Certificates" step, tick the cert that matches
"Apple Distribution: digital shift oü (UVTJ336J2D)". If
multiple are listed, pick the one matching:
• Apple-side name: Apple Distribution
• Expires: 2027-03-20
• Serial number ends in: 8FE8DF43 (visible when you click
into the cert in the portal)
Apple's API doesn't expose the "Created by" column the portal
shows — those three fields above are everything we have to
disambiguate. The wrong cert will silently fail at sign time.
The bullet list is conditional — when metadata isn't available
(e.g. ad_hoc path that skipped eager validation, or a transient
lookup failure) step 4 falls back to the original generic wording.
All existing unit tests still pass (apple-api-import-helpers,
macos-signing, onboarding-progress, onboarding-recovery).
Defense-in-depth at import-pick-profile.onChange. The existing check verified profile.bundleId === appId + profile.profileType === importDistribution. Adds the third invariant the build needs to actually succeed: profile.certificateSha1s.includes(chosenIdentity.sha1) Why this matters in practice: the manual portal walkthrough has six steps, step 4 being "tick the right cert in the allowed-certs list". If the user picks the wrong cert there, Apple happily generates the profile and we happily import it on rescan — but at sign time the build server uses the chosen identity's private key, the profile's allowed-certs list doesn't include the matching public cert, and the signature is rejected. The build fails far downstream from the actual mistake. The upstream filters (matchIdentitiesToProfiles for local scans, listProfilesForCert filtering for Apple-side) already ensure this in practice — but the rescan path can re-import user-created profiles that slipped through (e.g. the user's manual portal flow), and a future filter regression elsewhere could let mis-bound profiles pass the upstream gates. This catch fires with a specific error naming both SHA1 fingerprints (truncated to 8 chars for readability) so the user knows exactly which cert the profile lists vs which one they picked, and suggesting either pick-a-different-profile or recreate-with-correct-cert-tick as the fix. Sits alongside the bundleId/profileType defense in the same handler. Same error-handling pattern: handleError(...) routes to the existing error/retry screen with the support bundle.
Four small fixes to the import-portal-explanation step:
1. Apostrophe escapes (\\') rendering literally. JSX text content
doesn't need backslash-escapes for apostrophes — wrapping in JS
template literals OR using contractions inside JS expressions
{ `...` } renders cleanly. Replaced four broken occurrences:
"What you\'d need to do manually:" → "What you'd need to do manually:"
"Here\'s what it involves." → "Here's what it involves."
"Create it first if it doesn\'t…" → "Create it first if it doesn't…"
"you\'ll need to walk through…" → "you'll need to walk through…"
2. Added a new step 2 for "select the correct team in the team
selector (top right)" — Apple's portal defaults to whichever team
the user last viewed, and if they're in multiple teams the
create-profile flow silently scopes to the wrong one. Step now
names both the team ID and (when known) the team name from the
Keychain identity. If the user only has one team, we tell them
they're already on it so they don't go looking for a selector.
Old steps 2-6 → 3-7. The green "Recommended" callout's "all six
steps" copy switched to the wording-agnostic "all of the above".
3. Reassure the user that wrong-cert ticks in step 5 (was step 4) are
recoverable. Previously the inline note ended "The wrong cert will
silently fail at sign time." which is scary AND no longer
accurate now that the new cert-SHA1 check in import-pick-profile
onChange catches mis-bound profiles before saving creds.
New wording explicitly cites the downstream check:
"Don't worry if you're not 100% sure: when you pick the resulting
profile back in this CLI, we re-verify the cert SHA1 matches your
chosen identity and refuse with a clear error if it doesn't. So
a wrong tick here is recoverable."
Also softened the no-metadata fallback wording for step 5 to
match.
4. Removed the "Move the .mobileprovision into
~/Library/MobileDevice/Provisioning Profiles/ (or use the file
picker)" branch from step 6. Replaced with a single concrete path:
"Click Generate, Download. .mobileprovision lands in Downloads
folder. Come back and use 📁 Use a .mobileprovision file from
disk." Fewer choices, no need to know where Xcode's default
provisioning profile directory lives.
Step 7 (was 6) is now the file-picker click, not the rescan, and
includes a reassurance about the validation that happens on
import (bundle ID, distribution type, cert SHA1 — same checks the
in-line validator runs on any .mobileprovision selected from
disk).
Verified: tsc + bun run build clean.
The welcome handler skipped the Import-vs-Create-new fork on a clean
first run (no progress file, no existing credentials on disk):
welcome → ios/ exists? → loadSavedCredentials →
existing.ios → credentials-exist
else → api-key-instructions ← bug
`setup-method-select` was only reached via `backing-up`, which only
fires when the user picks "Start fresh (backup existing credentials
first)" from `credentials-exist`. So first-time users never saw the
Import option — they went straight to .p8 + Key ID + Issuer ID and
then the create-new path's cert-generation, even on macOS where
Importing an existing Keychain identity is the obvious primary
choice.
Routes the no-existing-creds branch to setup-method-select on macOS
so the user gets the fork. Non-macOS still goes straight to
api-key-instructions because the Import flow needs `security
find-identity` + the keychain-export Swift helper, neither of which
work on Linux/Windows.
User-reported regression: opening the CLI showed the App Store
Connect API Key prompt as Step 1 with no prior method choice.
The recovery menu's "Rescan Apple API for profiles" option created a
parallel path to the new file-picker flow. The portal walkthrough now
always tells the user to come back via "📁 Use a .mobileprovision file
from disk" — keeping Rescan made the recovery menu say "or use the
rescan instead" while the walkthrough we just rendered didn't mention
it, which is inconsistent UX.
Removed:
- rescanOption + spread in the no-match recovery menu
- 'fetch' branch in the recovery onChange handler
- import-fetching-profile step (entire useEffect, spinner UI, type,
progress entry, getPhaseLabel case)
- 'fetching-profile' variant from pendingRecoveryAction
- "Rescan Apple API" log nudge in the open-portal-anyway branch
Also tightened wording:
- open-anyway log now points at "📁 Use a .mobileprovision file from
disk" (matches the walkthrough's step 7).
- ad_hoc create-profile-only error message no longer offers a
non-existent "Fetch matching profile from Apple" option.
- Comments referencing the rescan flow now describe the file-picker
flow that replaced it.
Auto-fetch during import-checking-apple-cert is unchanged: when Apple
returns matching profiles, we still route straight to
import-pick-profile without surfacing the recovery menu at all.
… pbxproj disagree
Adds a `confirm-app-id` step that asks the user to pick the bundle ID we
should send to Apple when `capacitor.config.appId` and project.pbxproj's
PRODUCT_BUNDLE_IDENTIFIER disagree (common when capacitor.config carries
a dev-tunnel suffix that Apple Dev Portal doesn't know about).
The step only renders when there IS a mismatch (silent otherwise), and
the choice is persisted to onboarding progress so resume doesn't re-ask.
Scope of the override:
- Apple-side ops use the confirmed value: filterProfilesForApp,
ensureBundleId, createProfile, the synthesized DiscoveredProfile
bundleId, and the provisioning_map key written to credentials.json.
- Everything Capgo-side keeps using capacitor.config.appId:
loadProgress / saveProgress / deleteProgress, loadSavedCredentials,
updateSavedCredentials, the buildRequestCommand. So `capgo build`
after onboarding finds these credentials without forcing the user
to also edit capacitor.config.
Detection helper (`bundle-id-detector.ts`) reads project.pbxproj first
(preferring Release config, shortest bundle id to avoid app-extension
children) and falls back to Info.plist's CFBundleIdentifier when it's
a literal (not the `$(PRODUCT_BUNDLE_IDENTIFIER)` placeholder). 17 unit
tests cover empty input, multi-config pbxproj, extension parent/child
preference, variable-reference skipping, and the filesystem layout
search (ios/App.xcodeproj + root-level fallback).
Wired in at two natural gate points, both right before appId starts
being used for Apple work:
- End of `import-scanning` (ad_hoc flow that skips .p8)
- End of `verifying-key` (app_store flow after .p8 is verified)
UX:
- Pre-fills the recommended candidate (pbxproj > plist > capacitor)
in a Select listing each source with its value.
- "Type a custom bundle ID..." option opens a FilteredTextInput with
the recommended value as initial text, restricted to
[A-Za-z0-9._-] and 155 chars max.
- Yellow log line records which value was chosen + which capacitor
value it overrode, so the audit trail is in the on-screen log.
…ugin override
`getAppId()` resolves to `config.plugins.CapacitorUpdater.appId` when set
— that's the Capgo lookup key for dev-tunnel sandboxes and is correct
for progress files / credentials store / build requests. But it is NEVER
what `cap sync` writes into project.pbxproj's PRODUCT_BUNDLE_IDENTIFIER,
so it's the wrong starting point for Apple-side operations.
Real example (capacitor.config.ts):
appId: "app.capgo.plugin.TutorialBuild",
plugins: {
CapacitorUpdater: {
appId: "app.capgo.plugin.TutorialBuild.dev-ivkm-nnmp-c1rp",
},
},
Before this commit, the onboarding UI would fall back to the suffixed
value when no override existed — and the "No provisioning profile on
this Mac matches this app" warning would show the dev-tunnel ID even
though Apple Dev Portal only knows the un-suffixed bundle.
The fix is a clean separation:
- `appId` prop (unchanged): the Capgo lookup key. Used for progress
file, credentials key, buildRequestCommand. Resolved by `getAppId()`.
- `iosBundleIdInitial` prop (new): the iOS-side default, read from
`config.appId` directly. Falls back to `appId` only when config.appId
is somehow absent (rare — required for `cap sync`).
The detector now seeds its `capacitorAppId` argument from
`iosBundleIdInitial`, and the `iosBundleId` state starts from the same
value. When pbxproj and config.appId agree (the common case after
`cap sync`), no mismatch is detected and the confirm-app-id step stays
silent — but the warnings/errors now show the correct iOS bundle ID
regardless. When they disagree, the user picks at confirm-app-id as
before.
No test-suite change: the existing 17 detector tests still pass because
the detector contract is unchanged — only the value command.ts passes
into `capacitorAppId` shifts from getAppId() to config.appId.
…ABLE The "Profiles" column in the available-identities picker was rendering counts like "1/1 ✓" or "0/3" — readable but forces the user to mentally decode "is this identity ready to sign with as-is or not?" Replaced with a binary green AVAILABLE / red UNAVAILABLE label so the at-a-glance signal is unambiguous. Renamed the column to "Profile" (singular) since it now reports the status of profile availability, not a count. Underlying decision still uses filterProfilesForApp(...) so an identity is AVAILABLE iff there's at least one on-disk profile matching this app's bundle id + dist mode. Note: UNAVAILABLE here means "no ready-to-use profile on disk" — the identity itself is still pickable; the user will land on the no-match recovery menu (file picker, Apple create, etc.) which is the same escape hatch documented elsewhere in this flow. cellColor now switches on the literal AVAILABLE / UNAVAILABLE strings instead of looking for a "✓" substring.
…between runs
The previous version trusted `progress.iosBundleIdOverride` blindly —
once the user answered, the question never re-fired even when they
renamed the app in capacitor.config.ts between CLI runs. Reported by a
user who landed straight on "Step 2 · Choose certificate" with the
stale override silently applied.
Fix: snapshot `config.appId` (i.e. `iosBundleIdInitial`) into a new
`iosBundleIdContextAppId` progress field whenever the user picks at the
confirm-app-id step. On next run, compare the saved snapshot against
the current `config.appId`:
- match → the configuration the user confirmed against is unchanged;
trust the saved override (current behavior).
- differ → the user moved on; ignore the stale override, seed
iosBundleId from iosBundleIdInitial again, and let the
mismatch detector route through confirm-app-id one more
time so the user sees the current candidate list.
Only `config.appId` is tracked — pbxproj changes are usually downstream
of `cap sync` propagating from `config.appId`, so the snapshot
indirectly catches those without the user having to manage two fields.
Stale state also resets `appIdConfirmed` so the redirect helper treats
this session as if the user hasn't answered yet.
| // wording even though the key is verified) — visually obvious | ||
| // and recoverable, vs. a silent dead-end. | ||
| const certKnownOnApple = typeof appleCertIdForChosen === 'string' | ||
| const checkSkipped = !certKnownOnApple |
|



Summary
Two UX fixes for
capgo build init --platform ios(import-existing path):1. Empty picker → no-match-recovery (the main fix)
When the user has profiles for the identity they picked but none match the current app's bundle ID (or distribution mode), the picker showed:
That's a dead end. The user already has the
import-no-match-recoveryscreen (with Fetch from Apple, Create new App Store profile via Apple, and Open Developer Portal options) — we just weren't routing them there.Now the
filterProfilesForApp(profiles, appId, importDistribution)filter runs at:import-pick-profileentry — defense-in-depth covering Apple-fetch results, resume, and back-navigationIf nothing survives, we jump straight to
import-no-match-recovery. The alert wording there now distinguishes the two cases so the user understands what's actually wrong:This is what the user asked for: when the local list is empty for this app, offer to create the profile via the Apple API using their
.p8key (already implemented; we just needed to send users there).2. Dedupe consecutive identical log lines
✔ Distribution · app_storecould appear 3× in the rendered log.addLognow collapses consecutive identical entries — covers this symptom plus any other repeats from resume / back-navigation.Refactor
Extracted the
bundleId + distributionfilter (previously inline in 3 places inapp.tsx) asfilterProfilesForAppinmacos-signing.ts. Added 5 unit tests covering matching, mismatched bundleId, mismatched distribution, null/undefined distribution, and empty input.Test plan
bun run typecheck— cleanbunx eslint src/build/onboarding/ui/app.tsx src/build/onboarding/macos-signing.ts— only 7 pre-existing errors remain on main, none introduced by this PRbun test/test-macos-signing.mjs— 23 tests pass (including 5 newfilterProfilesForApptests)bun test/test-onboarding-recovery.mjs— all passbun test/test-apple-api-import-helpers.mjs— all passcapgo build initon a Mac with a distribution cert whose only profiles are for a different bundle ID — verify the flow lands atimport-no-match-recoverywith the new alert wording and "Create a new App Store profile via Apple" option✔ Distribution · …line appearsSummary by CodeRabbit
New Features
Tests