Skip to content

fix(cli/build init): route to recovery when no profiles match this app#2308

Open
WcaleNieWolny wants to merge 30 commits into
mainfrom
fix/cli-pick-profile-empty-recovery
Open

fix(cli/build init): route to recovery when no profiles match this app#2308
WcaleNieWolny wants to merge 30 commits into
mainfrom
fix/cli-pick-profile-empty-recovery

Conversation

@WcaleNieWolny
Copy link
Copy Markdown
Contributor

@WcaleNieWolny WcaleNieWolny commented May 21, 2026

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:

Pick a provisioning profile (0 matching this app's bundle ID and app_store distribution):
(1 other profile hidden — wrong bundle ID or distribution mode)
↩️   Back to identity selection

That's a dead end. The user already has the import-no-match-recovery screen (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:

  • identity selection — the natural choke point
  • import-pick-profile entry — defense-in-depth covering Apple-fetch results, resume, and back-navigation

If 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:

  • No profiles at all → "No provisioning profile on this Mac is linked to ''."
  • Profiles exist but none match → "No provisioning profile on this Mac matches this app (com.example.app) for app_store distribution under ''."

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 .p8 key (already implemented; we just needed to send users there).

2. Dedupe consecutive identical log lines

✔ Distribution · app_store could appear 3× in the rendered log. addLog now collapses consecutive identical entries — covers this symptom plus any other repeats from resume / back-navigation.

Refactor

Extracted the bundleId + distribution filter (previously inline in 3 places in app.tsx) as filterProfilesForApp in macos-signing.ts. Added 5 unit tests covering matching, mismatched bundleId, mismatched distribution, null/undefined distribution, and empty input.

Test plan

  • bun run typecheck — clean
  • bunx 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 PR
  • bun test/test-macos-signing.mjs — 23 tests pass (including 5 new filterProfilesForApp tests)
  • bun test/test-onboarding-recovery.mjs — all pass
  • bun test/test-apple-api-import-helpers.mjs — all pass
  • Manual: run capgo build init on a Mac with a distribution cert whose only profiles are for a different bundle ID — verify the flow lands at import-no-match-recovery with the new alert wording and "Create a new App Store profile via Apple" option
  • Manual: repeatedly enter / re-enter the Distribution selector — verify only one ✔ Distribution · … line appears

Summary by CodeRabbit

  • New Features

    • Improved provisioning-profile matching to avoid empty pickers and add a “switch to create new” recovery (persists choice) plus a macOS-only “switch back to import” escape
    • Visible “Checking certificate on Apple” onboarding step to pre-validate identities
    • Broader distribution-certificate matching and deduplicated log rendering
    • Clearer recovery messaging and pre-filled edit inputs when retrying verification
  • Tests

    • Added unit tests for provisioning profile filtering behavior

Review Change Stack

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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 21, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Profile Filtering Helper and macOS Import Flow

Layer / File(s) Summary
Profile filtering helper and unit tests
cli/src/build/onboarding/macos-signing.ts, cli/test/test-macos-signing.mjs
New filterProfilesForApp exported function filters provisioning profiles by bundleId and optionally by importDistribution (profileType), treating null/undefined as accepting any type. Unit tests validate bundleId+distribution matches, bundleId mismatch, distribution mismatch, null/undefined distribution behavior, and empty input handling.
Onboarding step and UI integration
cli/src/build/onboarding/types.ts, cli/src/build/onboarding/ui/app.tsx, cli/src/build/onboarding/ui/components.tsx
Adds 'import-checking-apple-cert' onboarding step and progress/label entries. Imports filterProfilesForApp into the UI, deduplicates consecutive log entries, guards entry to import-pick-profile by routing to import-no-match-recovery when no usable profiles exist, replaces inline filtering with the helper, adds an Apple-side cert pre-check step and tri-state appleCertIdForChosen, changes missing-Apple-cert error paths to log+route to recovery, expands import-no-match-recovery messaging and actions (including switch-create-new), adds macOS escape hatches for switching flows, and pre-fills FilteredTextInput on retry/edit.
Apple API certificate query
cli/src/build/onboarding/apple-api.ts
listDistributionCerts now queries both DISTRIBUTION and IOS_DISTRIBUTION with limit=200; added comments documenting rationale and exclusion of managed certs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Cap-go/capgo#2211: Introduced the macOS import-from-this-Mac provisioning-profile picker flow that this PR's filtering logic augments and refines.

Poem

🐰 I hopped through profiles, tidy and spry,
Matched bundles true beneath the sky,
When none fit, I showed the right way,
Tests hopped in to bless the play,
The import trail now hops okay.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main fix of routing to recovery when no profiles match the app, which is the primary focus of this PR.
Description check ✅ Passed The description covers all required sections: a detailed summary of the changes, a comprehensive test plan with specific steps, and a checklist with testing results. However, screenshots are not applicable since this is a backend/CLI change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq
Copy link
Copy Markdown
Contributor

codspeed-hq Bot commented May 21, 2026

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing fix/cli-pick-profile-empty-recovery (98189ef) with main (01e3575)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

…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).
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between a055a62 and 0082d88.

📒 Files selected for processing (2)
  • cli/src/build/onboarding/types.ts
  • cli/src/build/onboarding/ui/app.tsx

Comment thread cli/src/build/onboarding/ui/app.tsx
Comment thread cli/src/build/onboarding/ui/app.tsx Outdated
…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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 win

Use the filtered profile count in the identity picker.

m.profiles.length still 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 from filterProfilesForApp(...) 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 win

Hide the Import-existing recovery path off macOS.

cert-limit-prompt is reachable on Linux/Windows too, but this option always routes to import-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 win

Don't offer Switch to "Create new" after ad_hoc is selected.

This branch jumps into creating-certificate, which only creates App Store credentials in this flow. If importDistribution === '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

📥 Commits

Reviewing files that changed from the base of the PR and between 0082d88 and 42fad7f.

📒 Files selected for processing (2)
  • cli/src/build/onboarding/apple-api.ts
  • cli/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.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 value

Missing cancelled check before setStep in async IIFE.

Other async blocks in this useEffect check if (cancelled) return after async operations. This block calls await loadProgress(appId) but doesn't guard the subsequent setStep against 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

📥 Commits

Reviewing files that changed from the base of the PR and between 42fad7f and cd20598.

📒 Files selected for processing (2)
  • cli/src/build/onboarding/ui/app.tsx
  • cli/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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
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).
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
… 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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
…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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
…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.
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
…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
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants