Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
62f3664
fix(cli/build init): route to recovery when no profiles match this app
WcaleNieWolny May 21, 2026
cf216c1
Merge remote-tracking branch 'origin/main' into fix/cli-pick-profile-…
WcaleNieWolny May 21, 2026
a055a62
fix(cli/build init): don't dead-end when findCertIdBySha1 returns null
WcaleNieWolny May 21, 2026
0082d88
fix(cli/build init): pre-check Apple for the chosen cert before recov…
WcaleNieWolny May 21, 2026
d0d5075
fix(cli/build init): escape the create-new trap (rename + back-out pa…
WcaleNieWolny May 21, 2026
42fad7f
fix(cli/build init): query DISTRIBUTION + IOS_DISTRIBUTION cert types
WcaleNieWolny May 21, 2026
cd20598
fix(cli/build init): edit-in-place for Key ID / Issuer ID on verify fail
WcaleNieWolny May 21, 2026
ef8f45b
fix(cli/build init): add "Change .p8 key file" to verify-failure reco…
WcaleNieWolny May 21, 2026
a27ef9f
fix(cli/build init): enforce Key ID / Issuer ID format at input
WcaleNieWolny May 21, 2026
c0c4adf
fix(cli/build init): upsert field-update logs (no more old+new spam)
WcaleNieWolny May 21, 2026
fe0fa89
feat(cli/build init): two-table identity picker with eager Apple vali…
WcaleNieWolny May 21, 2026
7415eb0
fix(cli/build init): proper bordered Table component for the picker
WcaleNieWolny May 21, 2026
8bb8b7a
fix(cli/build init): use terminal display width (not codepoints) for …
WcaleNieWolny May 21, 2026
f12c2a0
fix(cli/apple-api): use /certificates/{id}/profiles relationship endp…
WcaleNieWolny May 21, 2026
f74ccaa
fix(cli/apple-api): list-then-filter for profiles by certificate id
WcaleNieWolny May 21, 2026
98189ef
fix(cli/build init): explain why Apple-fetched profiles aren't usable
WcaleNieWolny May 21, 2026
8d986ec
fix(cli/build init): auto-fetch profiles from Apple in the pre-check
WcaleNieWolny May 21, 2026
fad4ca6
feat(cli/build init): import .mobileprovision file from disk
WcaleNieWolny May 21, 2026
80f3def
fix(cli/build init): reorder recovery menu, drop unreachable Scenario B
WcaleNieWolny May 21, 2026
0ba7f1c
feat(cli/build init): PiP tutorial when opening the Apple Developer P…
WcaleNieWolny May 22, 2026
94f2d92
revert(cli/build init): drop PiP tutorial, add Open Portal explainer
WcaleNieWolny May 22, 2026
254d82e
feat(cli/build init): surface cert metadata in manual portal walkthrough
WcaleNieWolny May 22, 2026
39aa3e9
fix(cli/build init): verify profile cert SHA1 matches chosen identity
WcaleNieWolny May 22, 2026
1972f0d
fix(cli/build init): polish manual portal walkthrough
WcaleNieWolny May 22, 2026
adba7d1
fix(cli/build init): route fresh macOS runs through setup-method-select
WcaleNieWolny May 22, 2026
36a7c28
fix(cli/build init): drop Rescan option from no-match recovery menu
WcaleNieWolny May 22, 2026
3bf05bc
feat(cli/build init): confirm iOS bundle ID when capacitor.config and…
WcaleNieWolny May 22, 2026
b5aceae
fix(cli/build init): seed iOS bundle ID from config.appId, not the pl…
WcaleNieWolny May 22, 2026
34b7ef2
fix(cli/build init): replace X/Y profile ratio with AVAILABLE/UNAVAIL…
WcaleNieWolny May 22, 2026
fb9baae
fix(cli/build init): re-ask confirm-app-id when config.appId changed …
WcaleNieWolny May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@
"test:payload-split": "bun test/test-payload-split.mjs",
"test:macos-signing": "bun test/test-macos-signing.mjs",
"test:apple-api-import-helpers": "bun test/test-apple-api-import-helpers.mjs",
"test:bundle-id-detector": "bun test/test-bundle-id-detector.mjs",
"test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown",
"test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:credentials && bun run test:credentials-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown",
"test:build-platform-selection": "bun test/test-build-platform-selection.mjs",
"test:ai-log-capture": "bun test/test-ai-log-capture.mjs",
"test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs",
Expand All @@ -106,7 +107,8 @@
"jsonwebtoken": "^9.0.3",
"node-forge": "^1.3.3",
"qrcode": "^1.5.4",
"react": "^18.3.1"
"react": "^18.3.1",
"string-width": "^8.2.1"
},
"devDependencies": {
"@antfu/eslint-config": "^7.0.0",
Expand Down
158 changes: 153 additions & 5 deletions cli/src/build/onboarding/apple-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,98 @@ export interface AscDistributionCert {
certificateContent?: string
}

// ─── Cert availability classifier ──────────────────────────────────

/**
* Why a local Keychain cert can't be used to ship builds.
*
* Concrete enumeration so the import-pick-identity UI can render a stable
* Reason column and so we can add specific guidance per reason (e.g.
* "managed" certs get a "can't sign locally" note, "not-visible" certs get
* a "Open Developer Portal to verify" note).
*/
export type CertAvailabilityReason
= | 'expired'
| 'managed' // DISTRIBUTION_MANAGED — Apple-HSM signed, can't sign locally
| 'not-visible' // lookup didn't find a SHA1 match (revoked / wrong team / filter limitation)
| 'check-failed' // network or API error during lookup
| 'no-private-key' // for .p12 path — cert exists but key was stripped

export interface CertAvailability {
available: boolean
reason?: CertAvailabilityReason
/** Short human-readable reason for display in the picker. */
reasonText?: string
/** When available — Apple-side cert resource id for downstream API calls. */
appleCertId?: string
}

/**
* Pure classifier: given a local cert + the result of an Apple-side lookup,
* decide whether it's usable for shipping builds and surface a short
* reasonText for the picker UI.
*
* Exported separately from the lookup function so we can unit-test the
* decision logic without mocking network calls. Callers compose:
*
* const certId = await findCertIdBySha1(token, identity.sha1)
* .catch(err => { lookupError = err; return null })
* const availability = classifyCertAvailability({
* localExpirationDate: identity.expirationDate,
* appleCertId: certId,
* lookupError,
* })
*
* The `expired` and `managed` branches don't need a lookup — they're checked
* up-front from local metadata. Callers can pass null `appleCertId` without
* having run the lookup at all when those local-side conditions already
* disqualify the identity.
*/
export function classifyCertAvailability(args: {
localExpirationDate?: string
isManaged?: boolean
appleCertId: string | null
lookupError?: unknown
}): CertAvailability {
// Local-side disqualifiers first — these don't require an API round-trip.
if (args.localExpirationDate) {
const exp = Date.parse(args.localExpirationDate)
if (!Number.isNaN(exp) && exp < Date.now()) {
return {
available: false,
reason: 'expired',
reasonText: `Expired (${args.localExpirationDate.split('T')[0]})`,
}
}
}
if (args.isManaged) {
return {
available: false,
reason: 'managed',
reasonText: 'Apple-managed — can\'t sign locally',
}
}
// Apple-side lookup outcomes.
if (args.lookupError) {
return {
available: false,
reason: 'check-failed',
reasonText: `Lookup failed: ${args.lookupError instanceof Error ? args.lookupError.message : String(args.lookupError)}`,
}
}
if (args.appleCertId) {
return { available: true, appleCertId: args.appleCertId }
}
// No match returned. We can't distinguish revoked vs. wrong-team vs. our
// own lookup having a buggy filter from the response alone, so surface a
// neutral reasonText that doesn't claim revocation we can't prove.
return {
available: false,
reason: 'not-visible',
reasonText: 'Not visible to current API key (revoked, different team, or lookup limitation)',
}
}

/**
* List all iOS distribution certificates.
*
Expand All @@ -121,8 +213,27 @@ export async function listDistributionCerts(
token: string,
options: { includeContent?: boolean } = {},
): Promise<AscDistributionCert[]> {
// Query BOTH cert types — the legacy iOS-specific and the newer cross-
// platform "Apple Distribution" — because Apple deprecated
// IOS_DISTRIBUTION around 2021 and new certs created from App Store
// Connect now default to DISTRIBUTION. A team that has churned through
// certs over the years almost always has both types in its ledger; an
// IOS_DISTRIBUTION-only filter silently excludes the newer ones and
// produces a false negative when matching against a local Keychain
// identity named "Apple Distribution:" (= DISTRIBUTION type).
//
// limit=200 is Apple's documented max for this endpoint and is wildly
// higher than the per-team cert limits, so pagination is unnecessary
// even for the most prolific teams.
//
// DISTRIBUTION_MANAGED is intentionally NOT in the filter — those certs
// are signed using Apple-held private keys (Xcode Cloud / managed
// signing) and cannot be used to sign builds on third-party CI like
// Capgo. Including them would surface unusable identities in the
// picker. They'll still appear in the Available/Unavailable table view
// (Phase 2) marked "Apple-managed — can't sign locally".
const body = await ascFetch(
'/certificates?filter[certificateType]=IOS_DISTRIBUTION&limit=10',
'/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200',
token,
)
return (body.data || []).map((c: any): AscDistributionCert => ({
Expand Down Expand Up @@ -166,13 +277,31 @@ export function computeCertSha1(certificateContentBase64: string): string {
* creation. Returns null if no Apple-side cert matches the SHA1.
*/
export async function findCertIdBySha1(token: string, sha1: string): Promise<string | null> {
const match = await findCertBySha1(token, sha1)
return match ? match.id : null
}

/**
* Like {@link findCertIdBySha1} but returns the full Apple-side cert
* record (id + name + expirationDate + serialNumber) when matched. Used
* by the eager batch validation so the picker / manual-portal-walkthrough
* step can surface concrete disambiguators (expiration date, last few
* chars of serial number — both visible in the Apple Developer Portal
* when the user clicks into a cert) that help the user pick the right
* row when multiple distribution certs are listed for the same team.
*
* Apple's API does NOT expose a "created by" field on certs (the portal
* UI shows it, but `/v1/certificates` doesn't return that column). The
* disambiguators we can give are expirationDate + serialNumber.
*/
export async function findCertBySha1(token: string, sha1: string): Promise<AscDistributionCert | null> {
const target = sha1.toLowerCase()
const certs = await listDistributionCerts(token, { includeContent: true })
for (const cert of certs) {
if (!cert.certificateContent)
continue
if (computeCertSha1(cert.certificateContent) === target)
return cert.id
return cert
}
return null
}
Expand All @@ -195,9 +324,21 @@ export async function listProfilesForCert(
token: string,
certificateId: string,
): Promise<AscProfileSummary[]> {
// The relationships filter does the server-side join for us
// There's no direct "profiles for a given cert" endpoint on the ASC
// API — both naïve attempts return 4xx:
// - `/profiles?filter[certificates]=X` → 400, "'certificates' is not
// a valid filter type" (filter is whitelisted to id / name /
// profileState / profileType only).
// - `/certificates/{id}/profiles` → 404, "The relationship
// 'profiles' does not exist" (certificates is the to-many
// side; profiles → certificates is the navigable direction).
//
// The supported approach is to list ALL profiles with
// `include=certificates,bundleId`, then filter client-side to those
// whose `relationships.certificates.data[]` array includes our cert id.
// Limit=200 is Apple's documented max for /profiles.
const body = await ascFetch(
`/profiles?filter[certificates]=${encodeURIComponent(certificateId)}&include=bundleId&limit=50`,
`/profiles?include=certificates,bundleId&limit=200`,
token,
)
const included: any[] = body.included || []
Expand All @@ -206,7 +347,14 @@ export async function listProfilesForCert(
if (item.type === 'bundleIds' && item.attributes?.identifier)
bundleById.set(item.id, item.attributes.identifier)
}
return (body.data || []).map((p: any): AscProfileSummary => {
// Client-side cert-id filter on `relationships.certificates.data[].id`.
// Apple's included response includes the cert resources too, but we
// only need the reference array to decide which profiles to keep.
const profiles = (body.data || []).filter((p: any) => {
const certs: { id: string }[] = p.relationships?.certificates?.data ?? []
return certs.some(c => c.id === certificateId)
})
return profiles.map((p: any): AscProfileSummary => {
const bundleRelId = p.relationships?.bundleId?.data?.id as string | undefined
return {
id: p.id,
Expand Down
Loading
Loading