Skip to content

feat(cli/build init): import existing iOS signing credentials from this Mac#2211

Open
WcaleNieWolny wants to merge 9 commits into
mainfrom
feat/cli-build-init-import-ios-creds
Open

feat(cli/build init): import existing iOS signing credentials from this Mac#2211
WcaleNieWolny wants to merge 9 commits into
mainfrom
feat/cli-build-init-import-ios-creds

Conversation

@WcaleNieWolny
Copy link
Copy Markdown
Contributor

@WcaleNieWolny WcaleNieWolny commented May 11, 2026

Summary

build init now offers an "Import existing from this Mac" branch as a peer of the existing "Create new via App Store Connect API" path. iOS developers who already have a distribution cert in their login Keychain and a matching Xcode provisioning profile can now finish onboarding without revoking certs or touching the Apple Developer Portal.

Closes the user-reported gap: "the onboarding will never allow the user to export the key from their machine."

What changes for the user

When a macOS user runs npx @capgo/cli build init and selects iOS, they now see:

How do you want to set up iOS credentials?
  📥  Import existing from this Mac (Keychain + Xcode profiles)
  🆕  Create new via App Store Connect API

The import branch:

  • silently inventories security find-identity + scans both legacy (~/Library/MobileDevice/Provisioning Profiles) and Xcode 16+ (~/Library/Developer/Xcode/UserData/Provisioning Profiles) directories — no Keychain prompt during inventory
  • matches identities to profiles by SHA1 of embedded developer certs
  • prompts the user to pick an identity + a matching profile + distribution mode
  • shows a clear heads-up: "macOS will pop a Keychain dialog. Click Always Allow."
  • exports the chosen identity as a single-cert PKCS#12 via /usr/bin/security export + node-forge filtering — exactly one Keychain GUI dialog, regardless of how many identities you have
  • routes ad_hoc straight to saving credentials; app_store reuses the existing .p8 / Key ID / Issuer ID screens for TestFlight upload

Why this is needed

The current iOS flow is single-track and Apple-API-driven: it always creates a brand-new distribution cert + provisioning profile via the App Store Connect API. Two failure modes:

  1. Apple's 3-cert limit. Users at the limit are forced to revoke an existing cert (which may be in active use by CI or other teammates). The import path sidesteps this entirely.
  2. No ASC API key access. Some developers (contractors, smaller teams) don't have Admin access to App Store Connect and can't generate the .p8. For ad_hoc distribution, importing makes the .p8 requirement go away entirely.

Implementation

  • New module: cli/src/build/onboarding/macos-signing.ts — wraps /usr/bin/security subprocess calls, scans provisioning-profile directories, matches identities to profiles, and exports a filtered single-identity P12. Pure functions where possible; subprocess runner is dependency-injected for testing.
  • Extended cli/src/build/mobileprovision-parser.ts — added parseMobileprovisionDetailed() that exposes team ID, expiration date, profile type (app_store / ad_hoc / development / enterprise), and SHA1 of every embedded developer cert. Existing parseMobileprovision and parseMobileprovisionFromBase64 are unchanged.
  • Extended cli/src/build/onboarding/types.ts — 7 new step values + corresponding STEP_PROGRESS entries + getPhaseLabel cases. All additive; existing step values unchanged.
  • Extended cli/src/build/onboarding/ui/app.tsx — adds the fork at platform-select (gated by isMacOS()), the import sub-flow renders, and corresponding useEffect handlers. doSaveCredentials is updated to handle the import-mode case.

v1 scope

In Out
Single-bundle apps Multi-bundle / extensions / widgets (use existing build credentials update --ios-provisioning-profile bundleId=path)
login.keychain-db (default Keychain) Custom keychains
Both app_store and ad_hoc distribution Enterprise distribution
macOS only Linux/Windows (fork hidden, existing path unchanged)

Security considerations

  • Generates a cryptographically random 32-byte hex passphrase per export — no static passphrase
  • Stored in ~/.capgo-credentials/credentials.json at 0o600 alongside existing static 'capgo' passphrase from create-new path → no regression
  • The security export call writes a temp .p12 into a mkdtemp directory and rm -rfs it in finally whether export succeeds or fails
  • Subprocess uses spawn(SECURITY_BIN, [...args]) with absolute path and array argv — no shell interpolation

Test plan

  • bun run typecheck clean
  • bun run lint clean
  • bun run test:macos-signing — 11/11 pass
  • bun test/test-mobileprovision-parser.mjs — 9/9 pass (4 existing + 5 new)
  • Existing regression tests: test:credentials (15/15), test:credentials-validation (13/13), test:provisioning-map-validation (4/4), test:platform-paths (4/4)
  • Manual smoke test on a Mac with iOS distribution cert in Keychain — before marking ready
  • Verify exported P12 imports on build server (security import on Linux) — confirm on first end-to-end build

Pre-existing failure (not introduced by this PR)

test:onboarding-recovery has 1 pre-existing failure in updater install state requires node_modules install on origin/main. None of the files touched here relate to readUpdaterState or updater.ts.

Rollback

Remove the 'import' option from the Select widget in the setup-method-select render branch. The macos-signing.ts module + new step values become dead code (kept until next cleanup) but harmless.

Summary by CodeRabbit

  • New Features

    • macOS import flow to discover Keychain signing identities, match local provisioning profiles, select identity/profile, and export PKCS#12 for onboarding.
    • Added a Swift helper CLI to securely export a Keychain identity for macOS exports.
  • Enhancements

    • Mobileprovision parser now returns detailed metadata (team, expiration, profile type, certificate SHA1s).
    • Onboarding UI/resume flow updated for macOS import-existing scenarios.
  • Tests

    • Added unit tests for SHA1 utilities, mobileprovision parsing, and macOS signing helpers.
  • Chores

    • Build/test scripts updated and Swift helper shipped with CLI builds.

Review Change Stack

…is Mac

`build init` now offers an "Import existing from this Mac" branch as a peer
of the existing "Create new via App Store Connect API" path. Users with a
distribution cert already in their login Keychain and a matching Xcode
provisioning profile can finish onboarding without revoking certs or
touching the Apple Developer Portal.

The new branch:
  - silently inventories `security find-identity` + scans both legacy and
    Xcode 16+ provisioning-profile dirs (no Keychain prompt)
  - matches identities to profiles by SHA1 of embedded developer certs
  - asks the user to pick an identity + a matching profile
  - asks for distribution mode (app_store / ad_hoc)
  - exports a single-identity PKCS#12 via `security export` + node-forge
    filtering — this is the *only* macOS Keychain dialog
  - routes ad_hoc straight to saving-credentials; app_store reuses the
    existing .p8 / Key ID / Issuer ID screens for TestFlight upload

macOS-only: the fork is hidden on non-Darwin hosts (Linux CI still gets
the create-new path unchanged).

New module: cli/src/build/onboarding/macos-signing.ts with injectable
subprocess runner for testability. mobileprovision-parser gains a
parseMobileprovisionDetailed function exposing team ID, expiration,
profile type, and embedded cert SHA1s.

Tests: 11 new tests for macos-signing (parsing + matching + filesystem
scan against a fake home dir) + 5 new tests for the detailed parser.
All existing onboarding/credentials tests still pass.

v1 scope:
  - single-bundle apps (extensions/widgets fall back to the existing
    `build credentials update --ios-provisioning-profile bundleId=path`
    flow with a warning banner)
  - login.keychain-db only (custom keychains out of scope for v1)
  - both app_store and ad_hoc distribution modes
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

📝 Walkthrough

Walkthrough

Adds a macOS "import existing credentials from this Mac" onboarding flow plus supporting tooling: a Swift keychain-export helper, macOS signing utilities, detailed mobileprovision parsing, UI wiring/resume logic, tests, and build/package updates to ship and run the new helper and tests.

Changes

macOS onboarding / signing feature

Layer / File(s) Summary
Types, progress, and UI surface
cli/src/build/onboarding/types.ts, cli/src/build/onboarding/progress.ts, cli/src/build/onboarding/ui/app.tsx
Introduce import-* onboarding steps and persisted setupMethod/importDistribution; update STEP_PROGRESS and getPhaseLabel; change resume logic to branch for import-existing; add UI state, step handlers, and render branches to scan identities/profiles, compile helper, export P12, and recover/fetch/create profiles.
macOS signing core: discovery, matching, export orchestration
cli/src/build/onboarding/macos-signing.ts
Add macOS-only API: listSigningIdentities, scanProvisioningProfiles, matchIdentitiesToProfiles, passphrase generation, helper compile/cache (precompileSwiftHelper/isHelperCached), exportP12FromKeychain (runs Swift helper, parses JSON result), and parseHelperJson. Includes subprocess runner and cleanup semantics.
Swift helper: Keychain export CLI
cli/src/build/onboarding/keychain-export.swift
New Swift CLI that finds a Keychain identity by certificate SHA1, exports it as PKCS#12 with a passphrase, writes file atomically with 0600 perms, and prints exactly one-line JSON for success/failure with typed exit codes. Includes argument validation, SHA1 computation, SecItem export logic, and robust JSON error reporting.
Apple API certificate/profile utilities
cli/src/build/onboarding/apple-api.ts
Return richer distribution cert objects (AscDistributionCert) with optional base64 content; add computeCertSha1, findCertIdBySha1, listProfilesForCert, and use the new cert shape in CertificateLimitError. Enables finding Apple-side profiles by cert SHA1.
Mobileprovision detailed parser
cli/src/build/mobileprovision-parser.ts
Add MobileprovisionDetail, parseMobileprovisionDetailed, and buffer-level logic to extract teamId, expirationDate, derived profileType, and compute lowercase SHA1s for embedded developer certificates. Generalize plist extraction to support non-string tags.

Packaging, tests, and build wiring

Layer / File(s) Summary
Build packaging
cli/build.mjs
After bundling, copy src/build/onboarding/keychain-export.swift into dist/keychain-export.swift so the Swift source is shipped with the CLI distribution.
Package scripts
cli/package.json
Add test:macos-signing and test:apple-api-import-helpers npm scripts and include them in the aggregate test script.
Unit & integration tests
cli/test/test-macos-signing.mjs, cli/test/test-mobileprovision-parser.mjs, cli/test/test-apple-api-import-helpers.mjs
Add tests for parsing security find-identity output, matching identities to profiles, passphrase generation, helper JSON parsing, and computeCertSha1; extend mobileprovision parser tests to verify MobileprovisionDetail fields and profile-type derivation. Tests create temporary fixtures and validate behavior including error cases.

Sequence Diagram(s)

sequenceDiagram
    participant CLI as Onboarding CLI (Node/TS)
    participant Swift as keychain-export (Swift helper)
    participant Keychain as macOS Keychain
    participant FS as Filesystem
    participant AppleAPI as App Store Connect API

    CLI->>CLI: scan identities (security) & scan profiles (mobileprovision)
    CLI->>CLI: match identities ↔ profiles by cert SHA1
    CLI->>Swift: compile helper if missing (swiftc)
    CLI->>Swift: run helper with --sha1,--output,--passphrase
    Swift->>Keychain: SecItemCopyMatching & SecItemExport (PKCS#12)
    Keychain-->>Swift: p12 Data / user-denied error
    Swift->>FS: write temp .p12 (atomic), chmod 0600
    Swift-->>CLI: emit single-line JSON (success/failure)
    CLI->>AppleAPI: optionally list/create profiles / attach certs
    CLI->>FS: cleanup temp files
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Suggested reviewers

  • zinc-builds

Poem

🐰 I hopped across the Mac keychain shore,
Compiled a helper, scanned profiles galore,
SHA1 matched with a careful look,
Exported P12 and shipped the book,
Now onboarding leaps — a rabbit's chore.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.27% 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 clearly and specifically summarizes the main feature: importing existing iOS signing credentials from the user's Mac, which is the primary objective of the changeset.
Description check ✅ Passed The description thoroughly covers all required sections: a detailed summary of what changes for the user, rationale for the feature, implementation approach with file-level breakdowns, scope and security considerations, and a comprehensive test plan with expected outcomes.
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.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/cli-build-init-import-ios-creds

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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 11, 2026

Merging this PR will not alter performance

✅ 43 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing feat/cli-build-init-import-ios-creds (774fb17) with main (908aa39)

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.

Comment thread cli/src/build/onboarding/macos-signing.ts Fixed

// Generate a fake DER cert and compute its SHA1
const fakeDer = Buffer.from('hello-world-fake-cert')
const expectedSha1 = createHash('sha1').update(fakeDer).digest('hex').toLowerCase()
Comment thread cli/test/test-mobileprovision-parser.mjs Fixed
Comment thread cli/src/build/onboarding/ui/app.tsx Fixed
Copy link
Copy Markdown

@Fleehopper Fleehopper left a comment

Choose a reason for hiding this comment

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

Left two things I would fix before marking this ready:

  1. cli/src/build/onboarding/macos-signing.ts:376 uses require('node-forge') inside the new export path. The CLI package is type: module and the TS config emits ESNext modules; this function is not covered by the new tests because they inject forgeFilter / avoid the real P12 filter path. On the actual import flow, filterP12ToSingleIdentity() can be the first time this branch runs, so I would switch this to an ESM-safe lazy import path or a top-level import * as forge from 'node-forge' and add a small test that exercises the default filter path enough to catch module loading.

  2. cli/src/build/onboarding/ui/app.tsx:1050-1083 lets the user select any profile that matches the certificate SHA1, regardless of bundle ID or profile type. That can include an old profile for a different app, wildcard/profile edge cases, or an enterprise profile even though enterprise is explicitly out of v1 scope. doSaveCredentials() then persists the map under chosenProfile.bundleId, so onboarding can finish successfully while the next build has no profile for the actual Capacitor app target. I would filter/warn at selection time to appId-compatible profiles and reject enterprise/development profiles unless that support is intentionally added.

I couldn't run the Bun test suite locally because this environment does not have bun on PATH, so this is a source review rather than a verified run.

… iOS setup-method fork

Previously "Import existing" was the first option. Putting "Create new"
first matches the conservative default — users hitting the screen for the
first time aren't pre-selecting key export — and keeps the existing-flow
muscle memory intact for users running `build init` after upgrade.
…recovery

v1.1 of the import-existing flow. Re-orders so the user picks distribution
mode FIRST (right after the silent scan), which makes the .p8 question
asked exactly once and exactly where it belongs:

- app_store → .p8 required (TestFlight upload + free profile recovery)
- ad_hoc    → no .p8 by default (preserves the contractor path)

Adds an `import-no-match-recovery` step that turns the previous hard-error
("no profile linked to this cert") into a 4-option menu:

  🌐 Open Apple Developer Portal (re-scan after 5s)
  🔍 Fetch matching profile from Apple via ASC API
  ✨ Create a new profile for this cert via ASC API (D2)
  ↩️ Back to identity selection

The "Fetch" path uses the existing ASC API to list profiles for the
chosen cert (matched by SHA1) and surfaces them through the normal
pick-profile UI. The "Create new profile" path (D2) reuses the existing
ensureBundleId + createProfile from apple-api.ts but skips cert creation
since the user already has one.

ad_hoc users who hit no-match can opt in to providing a .p8 inline; it's
used one-shot and is NOT persisted to credentials.json (the existing
import-mode save logic already excludes APPLE_KEY_* fields for ad_hoc).

Implementation details:

  apple-api.ts:
    - `listDistributionCerts` gains an `includeContent` option that surfaces
      the base64 DER (existing callers unaffected — non-breaking addition)
    - new `computeCertSha1(base64) -> hex` helper
    - new `findCertIdBySha1(token, sha1) -> AppleCertId | null` helper
    - new `listProfilesForCert(token, certId) -> AscProfileSummary[]` helper
    - `CertificateLimitError.certificates` typed via new `AscDistributionCert`

  types.ts:
    - 3 new step values: import-no-match-recovery, import-fetching-profile,
      import-create-profile-only
    - STEP_PROGRESS values re-tiered to show 4-of-4 progression
    - getPhaseLabel renamed (now Step 1-of-4 starts with distribution mode)

  ui/app.tsx:
    - import-distribution-mode moved to be the FIRST visible import step
    - app_store routes through existing api-key-instructions chain → after
      `verifying-key`, the new import-mode branch routes back to
      import-pick-identity (or resumes a pending recovery action)
    - import-pick-identity: no-match no longer dead-ends — routes to
      import-no-match-recovery instead
    - import-pick-profile: removed the dist-mode pre-selection logic
      (dist mode is already chosen upstream)
    - import-export-warning back-button now goes to import-pick-profile
    - import-exporting: removed the late api-key-instructions detour for
      app_store; goes straight to saving-credentials
    - 3 new render branches + 2 new useEffect handlers for fetching/creating
    - `chosenProfile` can now be synthesized from Apple-API response
      (no on-disk path); export logic reads profileBase64 from the
      synthesized object when path is empty

Tests:
  - 5 new pure-function tests for computeCertSha1
  - All v1 tests pass: macos-signing 11/11, mobileprovision 9/9,
    credentials 17/17, credentials-validation 13/13

Out of scope for v1.1:
  - "I'm at the cert limit but want to create a profile anyway" — not handled
    here because findCertIdBySha1 will return null when the cert isn't on
    Apple's side, with a clear "use Create new" fallback message
  - Saving the one-shot ad_hoc ASC key to progress.json — intentionally
    transient so the saved credentials don't carry an unused .p8 path

t('computeCertSha1 hashes base64-encoded DER bytes', () => {
const fakeDer = Buffer.from('hello-cert-der')
const expected = createHash('sha1').update(fakeDer).digest('hex').toLowerCase()
Comment thread cli/src/build/onboarding/ui/app.tsx Outdated
options={[
...matchedProfiles.map(p => ({
label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
value: p.path,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This breaks the Apple-side profile recovery path when the certificate has more than one profile. import-fetching-profile synthesizes those profiles with path: '', but this picker uses p.path as the option value and then resolves the selection with find(p => p.path === value). Every fetched profile therefore has the same empty value, so selecting the second/third profile either resolves to the first one or does not change selection at all.

That can save the wrong provisioning profile for a different bundle/distribution mode. Use a stable unique value here, for example p.path || p.uuid, and do the lookup against the same key.

type: 'profiles',
attributes: {
name: profileName,
profileType: 'IOS_APP_STORE',
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This ad-hoc recovery branch still creates an App Store profile.

The import flow lets the user choose ad_hoc, and the no-match recovery menu then offers to create a new profile for the existing certificate. That path calls createProfile(), but createProfile() hardcodes profileType: 'IOS_APP_STORE'. So an ad-hoc user can end up saving an App Store provisioning profile while the saved credentials say CAPGO_IOS_DISTRIBUTION: 'ad_hoc', which will not work for QR/direct device installs.

The profile type should be derived from importDistribution here, e.g. pass IOS_APP_ADHOC for ad-hoc recovery and keep IOS_APP_STORE for TestFlight.

Copy link
Copy Markdown

@digzrow-coder digzrow-coder left a comment

Choose a reason for hiding this comment

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

The import flow can save a provisioning profile that does not match the distribution mode selected earlier.

import-distribution-mode now asks the user to choose app_store or ad_hoc before identity/profile selection, and doSaveCredentials() later persists that original importDistribution. But import-pick-profile builds its options from all matchedProfiles for the identity without filtering or validating p.profileType against importDistribution; the Apple recovery path also appends every profile returned by listProfilesForCert().

So a user can choose App Store / TestFlight, then pick a locally matching ad-hoc profile. The saved credentials will have distribution: "app_store" and an ad-hoc provisioning profile, so onboarding appears successful but the first TestFlight/build signing path fails later with a hard-to-diagnose profile mismatch. The inverse is also possible for ad-hoc mode with an app-store profile.

Please filter the profile picker to the selected distribution mode, or validate before import-export-warning/saving-credentials and route the user back to the distribution/profile choice with a clear error. A regression test can cover a match set containing one app_store and one ad_hoc profile and assert only the selected mode is accepted.

@albercr3
Copy link
Copy Markdown

I think the Apple-side recovery path still has a selection bug when more than one profile is returned for the imported certificate.

import-fetching-profile synthesizes each Apple profile with path: '' because there is no local file, then appends all of them to importMatches. Later, import-pick-profile uses value: p.path for every option and resolves the selection with matchedProfiles.find(p => p.path === value). For Apple-recovered profiles, every option therefore has the same empty value, so selecting the second/third profile still resolves to the first synthesized profile. The log can show the chosen row, but chosenProfile will be whichever empty-path entry appears first.

This matters for the fallback flow because listProfilesForCert() can return multiple profiles for different bundle IDs or distribution types, and the user may be unable to pick the one that matches the app. Please use a stable unique picker value such as p.path || p.uuid (or an apple:${p.uuid} synthetic path) and resolve by the same key. A regression can seed two synthesized profiles with empty path and assert selecting the second one sets chosenProfile.uuid to the second profile.

Replaces the `security export -t identities` + node-forge filter pipeline
with a small Swift helper (keychain-export.swift) that uses Security
framework's SecItemExport(.formatPKCS12) on the chosen SecIdentity.

Why: `security export -t identities` exports EVERY identity in the user's
Keychain, triggering a separate macOS dialog per private key (4-8 prompts
per import for a typical iOS dev). The Swift helper touches only the
chosen identity, so macOS only prompts about that one.

Prompt count on first run drops from N×2 to exactly 2 (one for the
"access" ACL, one for the "export" ACL — both intrinsic to PKCS#12 export
on Xcode-imported non-extractable keys). Both decisions are cached when
the user clicks "Always Allow", so subsequent runs are silent.

Distribution model: source-only.
  - The .swift file ships in dist/keychain-export.swift via a one-line
    addition to build.mjs (no precompiled binary, no signing CI yet)
  - On first import-flow invocation, macos-signing.ts compiles via
    `swiftc` to $TMPDIR/capgo-keychain-export-v$VERSION
  - Atomic compile via `<path>.<rand>.tmp` + rename so partial files
    never land at the cache key
  - Versioned cache key → CLI upgrade triggers fresh compile
  - Tmp folder location → reboots / `periodic` daily naturally invalidate

Helper contract:
  - Takes --sha1 / --output / --passphrase
  - ALWAYS emits one line of JSON on stdout (success or failure) so the
    Node caller never has to parse stderr or guess from exit codes
  - Distinct exit code 4 for USER_DENIED so callers can offer retry vs.
    fall back without string-matching error messages

Removed: ~80 LOC of `filterP12ToSingleIdentity` node-forge surgery (the
v1.1 workaround for the multi-identity P12 problem). The Swift helper
produces a single-identity P12 directly, so node-forge re-encoding is no
longer needed for the import path.

Tests: 7 new tests for `parseHelperJson` (success, USER_DENIED, empty
stdout, unparseable JSON, non-object JSON, whitespace tolerance,
multi-line stdout).

User-facing constraint: requires Xcode Command Line Tools (`swiftc`).
Every iOS dev already has these — Capacitor itself depends on them for
`cap sync`. If `swiftc` is missing, the helper compile fails with a
clear "run xcode-select --install" message.

Deferred to follow-up:
  - Code signing + notarization + Developer ID cert provisioning
  - Precompiled universal binary distribution via optionalDependencies
  - Both unblock removing the `swiftc` prerequisite, but require Apple
    Developer Program infra changes — kept separate from this PR
…p, fix JSX whitespace

Three independent fixes bundled together — all in the build-init flow.

1. Resume from interrupted import flow no longer falls into the
   create-new path's certificate-creation step (which was triggering the
   3-cert-limit error for users at Apple's max). The fork choice now
   persists to ~/.capgo-credentials/onboarding/<appId>.json as
   `setupMethod`, and getResumeStep branches on it. Legacy progress
   files lacking the field default to create-new (no behavior change for
   existing users).

   The error screen's "Restart onboarding" button now also deletes the
   progress file and resets all in-memory import-flow state, so users
   stuck in the broken state can recover without manually rm'ing the
   progress JSON.

2. New `import-compiling-helper` step shown only on first run (or after
   tmpdir cleanup) — explicitly tells the user we're compiling the
   keychain-export Swift helper. Without this, the user would stare at
   a "look for the macOS dialog" spinner for 2-3 seconds while we
   silently invoked swiftc. Cache hit (the common case) skips this step
   entirely via a sync existsSync check on the version-keyed tmp path.

   Adds isHelperCached() and precompileSwiftHelper() to macos-signing.ts
   for the UI to use.

3. Five JSX whitespace bugs where text content adjacent to inline <Text>
   elements rendered without a separating space:
     - "Track it athttps://..."             → "Track it at https://..."
     - "Usecapgo build credentials save"    → "Use capgo build credentials save"
     - "Key ID(detected from filename):"    → "Key ID (detected from filename):"
     - "Key ID(shown next to the key...":"  → "Key ID (shown next to the key...):"
     - "Issuer ID(UUID at the very top...)" → "Issuer ID (UUID at the very top...)"

   Standard React/Ink behavior: newline + indentation between sibling
   elements doesn't render as a space. Fixed via the existing {' '}
   convention used elsewhere in the file.
@WcaleNieWolny WcaleNieWolny marked this pull request as ready for review May 14, 2026 16:20
Three independent CodeQL alerts:

1. ui/app.tsx — `importProfiles` declared but never read. The setter is
   still called from the import-scanning useEffect to record the full
   profile list, but the UI uses `importMatches` for display. Switched
   to leading-comma destructuring (`const [, setImportProfiles] = …`)
   so the state hook + setter remain valid for any future references
   without leaving an unused binding.

2. test/test-mobileprovision-parser.mjs — `Math.random()` feeding into
   a SHA1 createHash. Even in test code this trips the "weak RNG into a
   hash" rule. Switched to `randomBytes(8)` from node:crypto for the
   uniqueness suffix. The fake DER bytes carry no security weight either
   way — they're scaffolding that the parser hashes — but using
   randomBytes silences the alert and is also strictly better practice.

3. apple-api.ts + mobileprovision-parser.ts — `createHash('sha1')` calls
   flagged as "weak cryptographic algorithm." These are NOT a security
   primitive: macOS itself reports code-signing identities as cert-DER
   SHA1 (via `security find-identity`), and we have to use the same
   hash to look up an Apple-side cert by its on-Mac counterpart.
   Replacing with SHA256 would break the matching and isn't an option.
   Added clear doc comments + `// lgtm[js/weak-cryptographic-algorithm]`
   suppression with explanation at each site.

A fourth CodeQL alert about SHA1 in `filterP12ToSingleIdentity` will
auto-resolve once GitHub re-scans the latest tip — that function was
deleted in commit 5b16793 when the Swift helper replaced the
node-forge P12 surgery.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1f510606df

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread cli/src/build/onboarding/ui/app.tsx Outdated
Comment thread cli/src/build/onboarding/ui/app.tsx Outdated
options={[
...matchedProfiles.map(p => ({
label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
value: p.path,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use unique profile identifiers in the import profile picker

The profile selector keys options by p.path, but profiles synthesized from Apple responses use an empty path, so multiple fetched profiles end up with the same option value. In that case onChange always resolves the first matching profile and the user cannot actually choose among multiple Apple profiles linked to the certificate.

Useful? React with 👍 / 👎.

const { bundleIdResourceId } = await ensureBundleId(token, appId)
if (cancelled)
return
const profile = await createProfile(token, bundleIdResourceId, certId, appId)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor ad_hoc mode when creating recovery provisioning profiles

The import recovery path always calls createProfile(...), which creates an IOS_APP_STORE profile. If the user chose ad_hoc distribution and hits "create profile" recovery, onboarding still saves CAPGO_IOS_DISTRIBUTION=ad_hoc but pairs it with an App Store provisioning profile, creating a mismatched signing configuration that can break ad-hoc installation flows.

Useful? React with 👍 / 👎.

…mport-ios-creds

# Conflicts:
#	cli/package.json
#	cli/src/build/onboarding/ui/app.tsx
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.

♻️ Duplicate comments (2)
cli/src/build/onboarding/apple-api.ts (1)

384-437: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

createProfile hardcodes App Store profile type, breaking ad-hoc imports.

The createProfile function hardcodes profileType: 'IOS_APP_STORE' at line 405. When the import flow's no-match recovery path creates a profile for an ad-hoc distribution, this results in saving an App Store profile while the credentials record CAPGO_IOS_DISTRIBUTION: 'ad_hoc', which will fail for QR/direct device installs.

🔧 Proposed fix

Add a profileType parameter to createProfile and pass it through to the API call:

 export async function createProfile(
   token: string,
   bundleIdResourceId: string,
   certificateId: string,
   appId: string,
+  profileType: 'IOS_APP_STORE' | 'IOS_APP_ADHOC' = 'IOS_APP_STORE',
 ): Promise<{
   profileId: string
   profileName: string
   profileContent: string
   expirationDate: string
 }> {
   const profileName = getCapgoProfileName(appId)

   try {
     const body = await ascFetch('/profiles', token, {
       method: 'POST',
       body: JSON.stringify({
         data: {
           type: 'profiles',
           attributes: {
             name: profileName,
-            profileType: 'IOS_APP_STORE',
+            profileType,
           },

Callers should pass 'IOS_APP_ADHOC' when importDistribution === 'ad_hoc' and 'IOS_APP_STORE' otherwise.

🤖 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/apple-api.ts` around lines 384 - 437, The
createProfile function currently hardcodes profileType to 'IOS_APP_STORE';
change its signature (createProfile) to accept a profileType parameter and use
that value in the ascFetch POST payload instead of the hardcoded string, and
update all callers (e.g., where import flow/no-match recovery calls
createProfile) to pass 'IOS_APP_ADHOC' when importDistribution === 'ad_hoc' and
'IOS_APP_STORE' otherwise so ad-hoc imports create the correct IOS_APP_ADHOC
provisioning profile.
cli/src/build/onboarding/ui/app.tsx (1)

1253-1288: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Selection bug: synthesized profiles all share empty path value.

Profiles fetched from Apple (via import-fetching-profile) are created with path: '' (line 558). Using p.path as the Select option value means all synthesized profiles have the same empty string value, so matchedProfiles.find(p => p.path === value) always returns the first match regardless of which row the user selected.

Use a stable unique key like p.path || p.uuid for both the option value and the lookup.

🐛 Proposed fix
 <Select
   options={[
     ...matchedProfiles.map(p => ({
       label: `📜  ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
-      value: p.path,
+      value: p.path || p.uuid,
     })),
     { label: '↩️   Back to identity selection', value: '__back__' },
   ]}
   onChange={(value) => {
     if (value === '__back__') {
       setStep('import-pick-identity')
       return
     }
-    const profile = matchedProfiles.find(p => p.path === value)
+    const profile = matchedProfiles.find(p => (p.path || p.uuid) === value)
     if (!profile)
       return
     setChosenProfile(profile)
     addLog(`✔ Profile · ${profile.name}`)
     setStep('import-export-warning')
   }}
 />
🤖 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 1253 - 1288, The Select
option values and lookup use p.path which is empty for synthesized profiles,
causing every option to share the same value; change the option value to a
stable unique key (e.g. use value: p.path || p.uuid) and update the onChange
lookup to find by that same key (e.g. find by (p.path || p.uuid) === value) so
matchedProfiles selection correctly resolves the chosen profile before calling
setChosenProfile, addLog and setStep in the import-pick-profile block.
🤖 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.

Duplicate comments:
In `@cli/src/build/onboarding/apple-api.ts`:
- Around line 384-437: The createProfile function currently hardcodes
profileType to 'IOS_APP_STORE'; change its signature (createProfile) to accept a
profileType parameter and use that value in the ascFetch POST payload instead of
the hardcoded string, and update all callers (e.g., where import flow/no-match
recovery calls createProfile) to pass 'IOS_APP_ADHOC' when importDistribution
=== 'ad_hoc' and 'IOS_APP_STORE' otherwise so ad-hoc imports create the correct
IOS_APP_ADHOC provisioning profile.

In `@cli/src/build/onboarding/ui/app.tsx`:
- Around line 1253-1288: The Select option values and lookup use p.path which is
empty for synthesized profiles, causing every option to share the same value;
change the option value to a stable unique key (e.g. use value: p.path ||
p.uuid) and update the onChange lookup to find by that same key (e.g. find by
(p.path || p.uuid) === value) so matchedProfiles selection correctly resolves
the chosen profile before calling setChosenProfile, addLog and setStep in the
import-pick-profile block.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75993644-c6a6-479a-a041-9defcd9db3f0

📥 Commits

Reviewing files that changed from the base of the PR and between af88b63 and 67bf743.

📒 Files selected for processing (12)
  • cli/build.mjs
  • cli/package.json
  • cli/src/build/mobileprovision-parser.ts
  • cli/src/build/onboarding/apple-api.ts
  • cli/src/build/onboarding/keychain-export.swift
  • cli/src/build/onboarding/macos-signing.ts
  • cli/src/build/onboarding/progress.ts
  • cli/src/build/onboarding/types.ts
  • cli/src/build/onboarding/ui/app.tsx
  • cli/test/test-apple-api-import-helpers.mjs
  • cli/test/test-macos-signing.mjs
  • cli/test/test-mobileprovision-parser.mjs

Codex caught a follow-up bug to the earlier setupMethod fix: after a CLI
restart in an import-existing flow, getResumeStep() correctly chooses
the right step (e.g. verifying-key or import-scanning), but the React
state that branches behavior INSIDE those steps wasn't being hydrated.

`importMode` defaulted to `false` and `importDistribution` to `null`,
both reset on every mount. Concrete failure:

  1. User picks Import → app_store, enters .p8/keyId/issuerId
  2. CLI closes mid `verifying-key`
  3. On restart: getResumeStep returns 'verifying-key'  ← correct
  4. UI mounts with importMode=false  ← STALE
  5. verifying-key useEffect: `if (importMode) ... else setStep('creating-certificate')`
     → branches into the create-new path
  6. Calls Apple to create a NEW distribution cert the user never asked for
  7. If user is at the 3-cert limit, blows up with CertificateLimitError
     (the exact symptom we already fixed once)
  8. Otherwise, silently creates and revokes certs

doSaveCredentials has the same shape of bug:
  `needsAscKey = !importMode || importDistribution === 'app_store'`
  → on a clean resume that completed the import, evaluates to
    `!false || null === 'app_store'` = `true`, so credentials get
    persisted as if create-new and the import path's no-ASC-for-ad_hoc
    invariant breaks.

Fix:

1. Hydrate `importMode` from `initialProgress?.setupMethod === 'import-existing'`.

2. Persist `importDistribution` to progress (new `OnboardingProgress.importDistribution`
   field) when the user picks it, hydrate it on mount. Could not infer
   distribution from .p8 presence because ad_hoc users CAN legitimately
   enter a one-shot .p8 during no-match recovery, which would otherwise
   make `.p8-presence-implies-app_store` an incorrect heuristic.

3. Clear setupMethod + importDistribution on cancel-out-of-import (the
   "↩️ Cancel and use Create new instead" option) so the next resume
   doesn't think the user is still mid-import.

Backward compatible: legacy progress files lacking either field default
to false / null, which is the same as today's behavior.
Codex caught a third resume-adjacent bug, this one in the no-match
recovery path:

The import-pick-profile Select keyed options by `p.path`:

    options=[
      ...profiles.map(p => ({ label: ..., value: p.path })),
      { label: 'Back', value: '__back__' },
    ]
    onChange=(value) => { matchedProfiles.find(p => p.path === value) }

Disk-discovered profiles each have a unique path, so this works fine for
the local-match case. But the D path (no-match-recovery → "Fetch from
Apple") synthesizes DiscoveredProfile entries with `path: ''` because
the profile lives only in memory at that point — it was returned by
the ASC API, not read from `~/Library/.../Provisioning Profiles`.

So when Apple returns N profiles linked to the chosen cert, the Select
has N options all with `value: ''`. The user picks one,
`matchedProfiles.find(p => p.path === '')` returns the FIRST match
regardless of which option the user actually highlighted. The user
cannot pick any non-first Apple-fetched profile.

Fix: key by `p.uuid` instead. UUIDs are unique for both kinds:
  - Disk-discovered profiles: the mobileprovision plist's UUID field
  - Apple-fetched (synthesized) profiles: the Apple resource ID (p.id)

Both flows already populate `uuid`, so no other changes needed.
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)

872-892: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Duplicate profile handling does not account for import mode — can corrupt flow.

When import-create-profile-only throws DuplicateProfileError (line 672), it routes to duplicate-profile-prompt. After the user deletes old profiles, line 885 unconditionally resumes to creating-profile, which is the create-new flow. In import mode, certData.certificateId is an empty string (line 544), so createProfile at line 839 will receive an invalid cert ID.

The fix: track which step triggered the duplicate-profile flow and resume to that step after deletion.

🐛 Sketch of proposed fix

Track the originating step in state when routing to duplicate-profile-prompt:

+ const [duplicateProfileRetryStep, setDuplicateProfileRetryStep] = useState<OnboardingStep>('creating-profile')

  // In import-create-profile-only error handler (around line 677):
  if (err instanceof DuplicateProfileError) {
    setDuplicateProfiles(err.profiles)
+   setDuplicateProfileRetryStep('import-create-profile-only')
    setStep('duplicate-profile-prompt')
  }

  // In creating-profile error handler (around line 862):
  if (err instanceof DuplicateProfileError) {
    setDuplicateProfiles(err.profiles)
+   setDuplicateProfileRetryStep('creating-profile')
    setStep('duplicate-profile-prompt')
  }

  // In deleting-duplicate-profiles (line 885):
- setStep('creating-profile')
+ setStep(duplicateProfileRetryStep)
🤖 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 872 - 892, When you
navigate to the 'duplicate-profile-prompt' flow from the catch in
import-create-profile-only (the branch that throws DuplicateProfileError),
record the originating step in component state (e.g., originStep or
previousStep) so it can be resumed; then in the 'deleting-duplicate-profiles'
handler, after successful deletion do not unconditionally
setStep('creating-profile') but setStep(originStep || 'creating-profile') so
import mode resumes the import path (preserving certData.certificateId
semantics) — update the code that routes to 'duplicate-profile-prompt' to set
this originStep and clear it after resuming.
🤖 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 872-892: When you navigate to the 'duplicate-profile-prompt' flow
from the catch in import-create-profile-only (the branch that throws
DuplicateProfileError), record the originating step in component state (e.g.,
originStep or previousStep) so it can be resumed; then in the
'deleting-duplicate-profiles' handler, after successful deletion do not
unconditionally setStep('creating-profile') but setStep(originStep ||
'creating-profile') so import mode resumes the import path (preserving
certData.certificateId semantics) — update the code that routes to
'duplicate-profile-prompt' to set this originStep and clear it after resuming.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a7946c5-43fe-4118-8ffe-bf4017eb9787

📥 Commits

Reviewing files that changed from the base of the PR and between d10c87d and 81a6f1c.

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

@sonarqubecloud
Copy link
Copy Markdown

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

🤖 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 672-678: The duplicate-profile recovery currently always sets
setStep('duplicate-profile-prompt') and loses context for import flows; modify
the DuplicateProfileError handling (the block that calls setDuplicateProfiles
and setStep) to preserve an import-specific retry target (e.g., store a
retryOrigin flag or enum alongside setDuplicateProfiles) when the error occurred
during an import path (when certData or import flow variables are present), and
on resume from the duplicate-profile prompt use that stored retry origin to
route back to the import-specific step ('import-create-profile-only' and
re-invoke findCertIdBySha1(...)) instead of blindly retrying 'creating-profile';
ensure the same stored token is set/cleared around deletion so "delete
duplicates and retry" returns to the correct import flow.
- Around line 649-664: The code always synthesizes an App Store profile after
createProfile by hardcoding profileType: 'app_store' and should honor the
selected distribution; update the call site around createProfile and the
synthesized object (symbols: createProfile, synthesized, DiscoveredProfile,
importDistribution, ad_hoc) so that you pass the selected distribution into
createProfile (or into a new parameter like profileType) and set
synthesized.profileType and related fields (e.g., certificateSha1s, bundleId,
applicationIdentifier) based on importDistribution instead of hardcoding
'app_store'; alternatively, if ad_hoc is not supported yet, hide/disable the
"create profile" recovery option when importDistribution === 'ad_hoc' to prevent
creating the wrong profile.
- Around line 1341-1368: The profile picker currently shows every profile in
matchedProfiles which allows selecting a profile whose bundleId or profileType
doesn't match the current appId/importDistribution; filter the options to only
show valid profiles by changing the source to matchedProfiles.filter(p =>
p.bundleId === appId && p.profileType === importDistribution) before mapping to
options, and also validate in the onChange handler (the find for profile by
uuid) that the chosen profile satisfies profile.bundleId === appId &&
profile.profileType === importDistribution before calling
setChosenProfile/addLog/setStep (or else show/return an error); update
references in this block (matchedProfiles, appId, importDistribution,
setChosenProfile, onChange) so both the UI and the continuation logic enforce
the same constraint.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d4381d7-77cc-449f-a2c9-f59dd8923e0e

📥 Commits

Reviewing files that changed from the base of the PR and between 81a6f1c and 774fb17.

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

Comment on lines +649 to +664
const profile = await createProfile(token, bundleIdResourceId, certId, appId)
if (cancelled)
return
// Use the freshly-created profile directly as the chosen profile.
const synthesized = {
path: '',
uuid: profile.profileId,
name: profile.profileName,
applicationIdentifier: '',
bundleId: appId,
teamId: chosenIdentity.teamId,
expirationDate: profile.expirationDate,
profileType: 'app_store' as const,
certificateSha1s: [chosenIdentity.sha1],
profileBase64: profile.profileContent,
} as DiscoveredProfile & { profileBase64: string }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't hardcode App Store profile creation in the ad_hoc import flow.

This branch is reachable after choosing ad_hoc, but it always creates/synthesizes an App Store profile. That means the saved credentials can end up with CAPGO_IOS_DISTRIBUTION='ad_hoc' paired with an App Store mobileprovision.

Either pass the selected distribution into profile creation here, or hide/disable the "create profile" recovery option when importDistribution === 'ad_hoc' until ad-hoc creation is implemented.

🤖 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 649 - 664, The code always
synthesizes an App Store profile after createProfile by hardcoding profileType:
'app_store' and should honor the selected distribution; update the call site
around createProfile and the synthesized object (symbols: createProfile,
synthesized, DiscoveredProfile, importDistribution, ad_hoc) so that you pass the
selected distribution into createProfile (or into a new parameter like
profileType) and set synthesized.profileType and related fields (e.g.,
certificateSha1s, bundleId, applicationIdentifier) based on importDistribution
instead of hardcoding 'app_store'; alternatively, if ad_hoc is not supported
yet, hide/disable the "create profile" recovery option when importDistribution
=== 'ad_hoc' to prevent creating the wrong profile.

Comment on lines +672 to +678
if (err instanceof DuplicateProfileError) {
// Existing flow already handles this case for the create-new path —
// route to the existing duplicate-profile-prompt step, which on
// resume will retry creation. Set the duplicateProfiles state so
// the prompt renders correctly.
setDuplicateProfiles(err.profiles)
setStep('duplicate-profile-prompt')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve an import-specific retry target for duplicate-profile recovery.

Reusing the shared duplicate-profile prompt here breaks the import path: after deletion, the flow retries creating-profile, but this branch hasn't populated certData and should go back through import-create-profile-only/findCertIdBySha1(...) instead. As written, "delete duplicates and retry" can't succeed for imported certificates.

Please carry the retry origin through the duplicate-profile flow and route back to the import-specific step after deletion.

🤖 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 672 - 678, The
duplicate-profile recovery currently always sets
setStep('duplicate-profile-prompt') and loses context for import flows; modify
the DuplicateProfileError handling (the block that calls setDuplicateProfiles
and setStep) to preserve an import-specific retry target (e.g., store a
retryOrigin flag or enum alongside setDuplicateProfiles) when the error occurred
during an import path (when certData or import flow variables are present), and
on resume from the duplicate-profile prompt use that stored retry origin to
route back to the import-specific step ('import-create-profile-only' and
re-invoke findCertIdBySha1(...)) instead of blindly retrying 'creating-profile';
ensure the same stored token is set/cleared around deletion so "delete
duplicates and retry" returns to the correct import flow.

Comment on lines +1341 to +1368
options={[
...matchedProfiles.map(p => ({
label: `📜 ${p.name} · bundle ${p.bundleId} · ${p.profileType} · expires ${p.expirationDate.split('T')[0]}`,
// Key by UUID, NOT path. Disk-discovered profiles have a
// unique path, but Apple-fetched profiles (from the D
// no-match-recovery path) are synthesized with path=''.
// Keying by path collapses every Apple-fetched profile to
// value="" and onChange's `find(p => p.path === '')` only
// ever resolves the first synthesized entry — user can't
// pick any other.
// UUID is unique for both kinds: disk profiles use the
// mobileprovision UUID, synthesized ones use Apple's
// profile resource ID.
value: p.uuid,
})),
{ label: '↩️ Back to identity selection', value: '__back__' },
]}
onChange={(value) => {
if (value === '__back__') {
setStep('import-pick-identity')
return
}
const profile = matchedProfiles.find(p => p.uuid === value)
if (!profile)
return
setChosenProfile(profile)
addLog(`✔ Profile · ${profile.name}`)
setStep('import-export-warning')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Filter the profile picker to the current app and distribution mode.

This still offers every profile attached to the certificate. If the cert is reused across apps or both App Store/ad-hoc, the user can select a profile with bundleId !== appId or profileType !== importDistribution, and doSaveCredentials() will save a mismatched provisioning map/distribution pair. That will produce unusable signing credentials for this app.

Please either filter matchedProfiles to p.bundleId === appId && p.profileType === importDistribution, or block continuation when the selected profile doesn't match both.

🤖 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 1341 - 1368, The profile
picker currently shows every profile in matchedProfiles which allows selecting a
profile whose bundleId or profileType doesn't match the current
appId/importDistribution; filter the options to only show valid profiles by
changing the source to matchedProfiles.filter(p => p.bundleId === appId &&
p.profileType === importDistribution) before mapping to options, and also
validate in the onChange handler (the find for profile by uuid) that the chosen
profile satisfies profile.bundleId === appId && profile.profileType ===
importDistribution before calling setChosenProfile/addLog/setStep (or else
show/return an error); update references in this block (matchedProfiles, appId,
importDistribution, setChosenProfile, onChange) so both the UI and the
continuation logic enforce the same constraint.

@digzrow-coder
Copy link
Copy Markdown

I reproduced the current CI blockers locally and pushed a small patch branch against this PR head:

What it fixes:

  • lint:deadcode: removes the unused DEFAULT_LOGIN_KEYCHAIN export and unused NoIdentitiesError class from cli/src/build/onboarding/macos-signing.ts.
  • typos: changes unparseable -> unparsable, avoids the mis-route spelling hit in the UI comment, and changes test fixture fake-udid to fake-device-id.

Validation I ran on the patch:

  • bun run lint:deadcode
  • typos v1.45.1 --config ./.typos.toml on the changed CLI files/tests
  • bun run typecheck from cli/
  • bun run test:macos-signing from cli/
  • bun run test:apple-api-import-helpers from cli/
  • bun test/test-mobileprovision-parser.mjs from cli/
  • git diff --check

I did not open a competing upstream PR since this is just a helper patch for #2211.

@digzrow-coder
Copy link
Copy Markdown

Update: I extended the helper branch to cover the CodeQL failures too and force-updated it to a single current commit:

Additional fix included:

  • Adds narrow lgtm[js/weak-cryptographic-algorithm] suppressions in the SHA1 certificate-fingerprint tests. These tests are intentionally checking Apple/Keychain SHA1 fingerprint compatibility, not using SHA1 for password/security hashing.

Validation rerun on a0279a763:

  • bun run lint:deadcode
  • typos v1.45.1 --config ./.typos.toml cli
  • bun run typecheck from cli/
  • bun run test:macos-signing from cli/
  • bun run test:apple-api-import-helpers from cli/
  • bun test/test-mobileprovision-parser.mjs from cli/
  • git diff --check

@digzrow-coder
Copy link
Copy Markdown

I put together a helper branch for the current CI/review blockers:

https://github.com/digzrow-coder/capgo/tree/codex/capgo-pr2211-helper-forkbase

What it addresses:

  • Fixes lint:deadcode by removing the unused macOS signing exports.
  • Fixes the current CLI typo failures (udid, unparseable, mis-route).
  • Avoids the CodeQL SHA1 test alerts by replacing test-side SHA1 recomputation with known expected hashes.
  • Preserves duplicate-profile retry context so import recovery returns to import-create-profile-only instead of always retrying creating-profile.
  • Makes import profile creation honor importDistribution (app_store vs ad_hoc) in the ASC profile type and synthesized profile metadata.
  • Filters import profile selection by both appId and importDistribution, and validates the selected UUID against the same filtered set.

Validated locally:

  • bun run lint:deadcode
  • typos ./cli --config ./.typos.toml
  • bun run --cwd cli test:macos-signing
  • bun run --cwd cli test:apple-api-import-helpers
  • bun test/test-mobileprovision-parser.mjs from cli/
  • bun run --cwd cli typecheck
  • bun run --cwd cli lint
  • git diff --check

I also tried full bun run --cwd cli test, but on this Windows shell it stops at test:version-detection:setup because Bun cannot execute ./test/fixtures/setup-test-projects.sh via the POSIX path. The targeted tests touched by this patch pass.

@digzrow-coder
Copy link
Copy Markdown

Follow-up: the fork CI for the helper branch is green now: https://github.com/digzrow-coder/capgo/actions/runs/25905147169

@radiantjade
Copy link
Copy Markdown

I think the helper cache path is too trusting for a flow that exports signing private keys.

compiledHelperPath() resolves to a predictable file in the shared OS temp directory (/tmp/capgo-keychain-export-v<version>), and ensureSwiftHelper() returns that path immediately when existsSync(cached) is true. On macOS /tmp is world-writable, so another local account or a prior compromised process can pre-create an executable at that versioned path before the CLI runs. The onboarding flow will then execute that binary as the current user and pass it the selected identity SHA1, output path, and freshly generated P12 passphrase. Because this helper is the component that receives Keychain export prompts and handles the P12, executing an unverified temp-file cache is a pretty sensitive trust boundary.

I would avoid a shared predictable executable here. Safer options are: compile into a per-user 0700 cache directory such as ~/Library/Caches/capgo/ and verify owner/mode/non-symlink before reuse, or skip the persistent cache and compile into a fresh mkdtemp() directory for each run. If caching remains, I would also hash the bundled Swift source/compiler output metadata and reject any cached helper whose owner/mode/hash do not match expectations. A regression can pre-create the cache path with a fake executable and assert ensureSwiftHelper() does not execute/reuse it.

@nasuf
Copy link
Copy Markdown

nasuf commented May 16, 2026

I think there is still a resume regression in the import path after the API key is verified.

import-distribution-mode persists setupMethod = 'import-existing' and importDistribution, and the component hydrates importMode / importDistribution from those fields on startup. But the verifying-key handler constructs a fresh OnboardingProgress object and saves it with only p8Path and completedSteps.apiKeyVerified; it does not preserve the existing setupMethod or importDistribution values.

That means an app-store import session can follow the intended path initially, then if the CLI is interrupted after verifying-key succeeds but before credentials are saved, the next run loads progress without setupMethod. getResumeStep()/state hydration then treats it as the create-new flow, which can send the user back toward certificate creation instead of import-pick-identity.

The fix should be to merge the existing progress record when saving apiKeyVerified here, or explicitly carry forward setupMethod and importDistribution into the new progress object. A small regression test can simulate: choose import-existing + app_store, run the verifying-key save, reload progress, and assert setupMethod === 'import-existing' and importDistribution === 'app_store' are still present.

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.

7 participants