// Code signing through the App Store Connect API (Certificates, Identifiers & Profiles), pure planning: // bundle id capabilities, which distribution certificate to use, which provisioning profile to (re)create, where // profiles are installed, and the ExportOptions.plist for `xcodebuild -exportArchive`. The command is // cli/apple/signing.ts; requests are JSON:API `Req`s (printed on --dry, sent otherwise). // API reference: https://developer.apple.com/documentation/appstoreconnectapi (Bundle IDs, Bundle ID Capabilities, // Certificates, Profiles). import { createHash, X509Certificate } from 'node:crypto'; import { join } from 'node:path'; import type { Req } from '../apple/jsonapi'; import { one, many } from '../apple/jsonapi'; import type { PlistValue } from './plist'; /** CapabilityType values of the App Store Connect API (BundleIdCapability.capabilityType). */ export const CAPABILITY_TYPES = ['ICLOUD', 'IN_APP_PURCHASE', 'GAME_CENTER', 'PUSH_NOTIFICATIONS', 'WALLET', 'INTER_APP_AUDIO', 'MAPS', 'ASSOCIATED_DOMAINS', 'PERSONAL_VPN', 'APP_GROUPS', 'HEALTHKIT', 'HOMEKIT', 'WIRELESS_ACCESSORY_CONFIGURATION', 'APPLE_PAY', 'DATA_PROTECTION', 'SIRIKIT', 'NETWORK_EXTENSIONS', 'MULTIPATH', 'HOT_SPOT', 'NFC_TAG_READING', 'CLASSKIT', 'AUTOFILL_CREDENTIAL_PROVIDER', 'ACCESS_WIFI_INFORMATION', 'NETWORK_CUSTOM_PROTOCOL', 'COREMEDIA_HLS_LOW_LATENCY', 'SYSTEM_EXTENSION_INSTALL', 'USER_MANAGEMENT', 'APPLE_ID_AUTH'] as const; export type CapabilityType = typeof CAPABILITY_TYPES[number]; /** Certificate types storebound creates: Apple Distribution (DISTRIBUTION, all platforms), the older iOS Distribution, * and Developer ID Application (Mac apps outside the App Store; Account Holder only). */ export const CERT_TYPES = ['DISTRIBUTION', 'IOS_DISTRIBUTION', 'DEVELOPER_ID_APPLICATION', 'DEVELOPER_ID_APPLICATION_G2', 'MAC_APP_DISTRIBUTION', 'MAC_INSTALLER_DISTRIBUTION'] as const; export type CertType = typeof CERT_TYPES[number]; /** Profile types for store builds (Profile.profileType). */ export const PROFILE_TYPES = ['IOS_APP_STORE', 'IOS_APP_ADHOC', 'TVOS_APP_STORE', 'MAC_APP_STORE', 'MAC_APP_DIRECT', 'MAC_CATALYST_APP_STORE'] as const; export type ProfileType = typeof PROFILE_TYPES[number]; export interface CapabilitySpec { type: CapabilityType; settings?: unknown[] } export interface AppleSigning { /** automatic (default): xcodebuild -allowProvisioningUpdates with the API key creates what it needs. * manual: the certificate and profiles from `storebound apple signing ensure`, named in ExportOptions.plist. */ style?: 'automatic' | 'manual'; /** Team ID (10 characters). Default: the bundle id's seedId from the API. */ teamId?: string; /** Default DISTRIBUTION (Apple Distribution). */ certificateType?: CertType; /** Default IOS_APP_STORE. */ profileType?: ProfileType; /** Capabilities the bundle id must have (Certificates, Identifiers & Profiles), e.g. ['IN_APP_PURCHASE', 'GAME_CENTER']. */ capabilities?: (CapabilityType | CapabilitySpec)[]; /** Bundle ids of app extensions / widgets / watch apps in the same archive: each gets its own profile. */ extraBundleIds?: string[]; } export function validateSigning(s: AppleSigning | undefined): string[] { if (!s) return []; const e: string[] = []; if (s.style && !['automatic', 'manual'].includes(s.style)) e.push('apple.signing.style: automatic | manual'); if (s.teamId && !/^[A-Z0-9]{10}$/.test(s.teamId)) e.push('apple.signing.teamId: the 10-character Team ID'); if (s.certificateType && !CERT_TYPES.includes(s.certificateType)) e.push(`apple.signing.certificateType: one of ${CERT_TYPES.join(', ')}`); if (s.profileType && !PROFILE_TYPES.includes(s.profileType)) e.push(`apple.signing.profileType: one of ${PROFILE_TYPES.join(', ')}`); for (const c of s.capabilities ?? []) { const t = typeof c === 'string' ? c : c?.type; if (!CAPABILITY_TYPES.includes(t as CapabilityType)) e.push(`apple.signing.capabilities: unknown capability "${String(t)}" (${CAPABILITY_TYPES.slice(0, 6).join(', ')}, ...)`); } for (const b of s.extraBundleIds ?? []) if (!/^[A-Za-z0-9.-]+$/.test(b)) e.push(`apple.signing.extraBundleIds: "${b}" is not a bundle id`); return e; } /** The capabilities the App ID must have: apple.signing.capabilities, plus GAME_CENTER when games.gameCenter is true. */ export const capabilitySpecs = (s: AppleSigning | undefined, gameCenter = false): CapabilitySpec[] => { const out = (s?.capabilities ?? []).map((c): CapabilitySpec => (typeof c === 'string' ? { type: c } : c)); if (gameCenter && !out.some(c => c.type === 'GAME_CENTER')) out.push({ type: 'GAME_CENTER' }); return out; }; /** Capabilities to add (never removes one: removing can break a shipped feature; extra ones are reported). */ export function planCapabilities(bundleIdId: string, existing: string[], want: CapabilitySpec[]): { create: Req[]; present: string[]; extra: string[] } { const have = new Set(existing); const create = want.filter(w => !have.has(w.type)).map((w): Req => ({ method: 'POST', path: '/v1/bundleIdCapabilities', note: `capability ${w.type}`, body: { data: { type: 'bundleIdCapabilities', attributes: { capabilityType: w.type, ...(w.settings ? { settings: w.settings } : {}) }, relationships: { bundleId: one('bundleIds', bundleIdId) } } }, })); return { create, present: want.filter(w => have.has(w.type)).map(w => w.type), extra: existing.filter(t => !want.some(w => w.type === t)) }; } export interface ApiCert { id: string; attributes: { certificateType?: string; certificateContent?: string; expirationDate?: string; displayName?: string; name?: string; serialNumber?: string; activated?: boolean } } /** SHA-1 of the certificate DER, upper-case hex: what `security find-identity` prints. */ export const certSha1 = (contentB64: string): string => createHash('sha1').update(Buffer.from(contentB64, 'base64')).digest('hex').toUpperCase(); /** SPKI (DER, base64) of a certificate's public key, or null when it does not parse. */ export function certSpki(contentB64: string): string | null { try { return new X509Certificate(Buffer.from(contentB64, 'base64')).publicKey.export({ type: 'spki', format: 'der' }).toString('base64'); } catch { return null; } } export interface CertChoice { cert: ApiCert | null; why: string; usable: ApiCert[]; withoutKey: ApiCert[]; expiring: ApiCert[] } /** A certificate is usable when it is of the wanted type, active, valid for 30 more days and this machine holds its * private key (a key file in .storebound/signing or a code signing identity in the keychain). Newest first. */ export function pickCertificate(certs: ApiCert[], type: CertType, hasKey: (c: ApiCert) => boolean, now = Date.now()): CertChoice { const ofType = certs.filter(c => c.attributes.certificateType === type && c.attributes.activated !== false); const valid = ofType.filter(c => Date.parse(c.attributes.expirationDate ?? '') > now); const expiring = valid.filter(c => Date.parse(c.attributes.expirationDate!) - now < 30 * 86_400_000); const usable = valid.filter(c => hasKey(c) && !expiring.includes(c)).sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!)); const withoutKey = valid.filter(c => !hasKey(c)); const cert = usable[0] ?? null; const why = cert ? `using ${cert.attributes.name ?? cert.attributes.displayName ?? type} (expires ${cert.attributes.expirationDate?.slice(0, 10)})` : withoutKey.length ? `${withoutKey.length} ${type} certificate(s) on the team, but this machine has none of their private keys: a new one is needed (or import the .p12 from the Mac that made it)` : `no valid ${type} certificate`; return { cert, why, usable, withoutKey, expiring }; } /** Apple caps distribution certificates per team (Apple Distribution: 3 per team). */ export const CERT_LIMIT: Partial> = { DISTRIBUTION: 3, IOS_DISTRIBUTION: 3, DEVELOPER_ID_APPLICATION: 5, DEVELOPER_ID_APPLICATION_G2: 5, MAC_APP_DISTRIBUTION: 3, MAC_INSTALLER_DISTRIBUTION: 3 }; export function certificateReq(type: CertType, csr: string): Req { return { method: 'POST', path: '/v1/certificates', note: `create ${type} certificate (key in .storebound/signing, never printed)`, body: { data: { type: 'certificates', attributes: { certificateType: type, csrContent: csr } } } }; } /** A 403 when creating a Developer ID certificate: only the Account Holder may create them. Explanation or null. */ export function explainCertError(err: unknown, type: CertType): string | null { const s = String(err); if (/DEVELOPER_ID/.test(type) && /-> 403/.test(s)) return 'Developer ID certificates can only be created by the Account Holder of the team. An API key (even Admin) gets 403 here. ' + 'The Account Holder creates it once in Xcode (Settings > Accounts > Manage Certificates > + > Developer ID Application) or at ' + 'developer.apple.com > Certificates, then exports the .p12 for this machine. App Store builds need DISTRIBUTION instead.'; if (/-> 409/.test(s) && /maximum|limit|already/i.test(s)) return 'The team has reached its limit of this certificate type. Revoke an unused one at developer.apple.com > Certificates (owner decision: ' + 'builds signed with it keep working, but its profiles become invalid), or use a certificate whose key is on another Mac (export a .p12 there).'; if (/-> 403/.test(s)) return 'The API key may not manage certificates: it needs the Admin role (Users and Access > Integrations).'; return null; } export interface ApiProfile { id: string; attributes: { name?: string; profileType?: string; profileState?: string; expirationDate?: string; uuid?: string; profileContent?: string }; certIds?: string[]; /** Xcode-managed profile (automatic signing, "iOS Team Store Provisioning Profile: ..."): listed under the bundle id, * but /v1/profiles/{id} answers 404 for it, so its certificates are unknown and storebound never reuses it. */ managed?: boolean; } /** The profiles of one bundle id of `type`, each with its certificate ids. A profile the profiles API does not serve * (404: Xcode-managed, seen live October 2026) is kept with `managed: true` instead of failing the whole read. */ export async function readProfiles(all: (path: string) => Promise<{ id: string; attributes?: Record }[]>, bundleIdId: string, type: ProfileType): Promise { const ps = await all(`/v1/bundleIds/${bundleIdId}/profiles?limit=200`); const out: ApiProfile[] = []; for (const p of ps.filter(p => p.attributes?.profileType === type)) { const attributes = p.attributes as ApiProfile['attributes']; try { const certs = await all(`/v1/profiles/${p.id}/relationships/certificates?limit=200`); out.push({ id: p.id, attributes, certIds: certs.map(x => x.id) }); } catch (e) { if (!/-> 404\b/.test((e as Error).message)) throw e; out.push({ id: p.id, attributes, managed: true }); } } return out; } export const profileName = (bundleId: string, type: ProfileType, date = new Date()): string => `storebound ${bundleId} ${type === 'IOS_APP_STORE' ? 'App Store' : type} ${date.toISOString().slice(0, 16).replace('T', ' ')}`; export interface ProfilePlan { reuse: ApiProfile | null; create: Req | null; why: string } /** Reuse an ACTIVE profile of the type that holds the certificate and is valid for 14 more days; else create one. */ export function planProfile(existing: ApiProfile[], o: { bundleId: string; bundleIdId: string; certId: string; type: ProfileType; now?: number }): ProfilePlan { const now = o.now ?? Date.now(); const ok = existing.filter(p => p.attributes.profileType === o.type && p.attributes.profileState === 'ACTIVE' && Date.parse(p.attributes.expirationDate ?? '') - now > 14 * 86_400_000 && (p.certIds ?? []).includes(o.certId)) .sort((a, b) => Date.parse(b.attributes.expirationDate!) - Date.parse(a.attributes.expirationDate!)); if (ok[0]) return { reuse: ok[0], create: null, why: `profile "${ok[0].attributes.name}" is active (expires ${ok[0].attributes.expirationDate?.slice(0, 10)})` }; const stale = existing.filter(p => p.attributes.profileType === o.type && !p.managed); const managed = existing.filter(p => p.attributes.profileType === o.type && p.managed); const why = [ stale.length ? `${stale.length} ${o.type} profile(s), none active with this certificate (states: ${[...new Set(stale.map(p => p.attributes.profileState))].join(', ')})` : '', managed.length ? `${managed.length} Xcode-managed (automatic signing; not readable through the API, not reused)` : '', ].filter(Boolean).join('; ') || `no ${o.type} profile`; return { reuse: null, why, create: { method: 'POST', path: '/v1/profiles', note: `create ${o.type} profile for ${o.bundleId}`, body: { data: { type: 'profiles', attributes: { name: profileName(o.bundleId, o.type, new Date(now)), profileType: o.type }, relationships: { bundleId: one('bundleIds', o.bundleIdId), certificates: many('certificates', [o.certId]) } } }, } }; } /** Where provisioning profiles are installed: Xcode 16 and later read ~/Library/Developer/Xcode/UserData/Provisioning * Profiles; older Xcode (and some tools) ~/Library/MobileDevice/Provisioning Profiles. storebound writes both. */ export const profileDirs = (home: string): string[] => [join(home, 'Library/Developer/Xcode/UserData/Provisioning Profiles'), join(home, 'Library/MobileDevice/Provisioning Profiles')]; /** Lines of `security find-identity -v -p codesigning` -> SHA-1 hashes of identities with a private key. */ export function parseIdentities(out: string): { sha1: string; name: string }[] { return [...out.matchAll(/^\s*\d+\)\s+([0-9A-F]{40})\s+"([^"]+)"/gm)].map(m => ({ sha1: m[1]!, name: m[2]! })); } export interface ExportOptionsInput { teamId: string; style: 'automatic' | 'manual'; profiles?: Record; method?: string; uploadSymbols?: boolean; certificate?: string } /** ExportOptions.plist for an App Store Connect export (`method` app-store-connect, Xcode 15.3+; "app-store" is the * deprecated name). destination export: storebound uploads the IPA itself (`apple upload`), with the build number it * chose, so Xcode must not change it (manageAppVersionAndBuildNumber false). */ export function exportOptions(i: ExportOptionsInput): Record { const o: Record = { method: i.method ?? 'app-store-connect', teamID: i.teamId, signingStyle: i.style, destination: 'export', uploadSymbols: i.uploadSymbols ?? true, manageAppVersionAndBuildNumber: false, }; if (i.style === 'manual') { if (!i.profiles || !Object.keys(i.profiles).length) throw new Error('manual signing needs a provisioning profile per bundle id (storebound apple signing ensure)'); o.provisioningProfiles = { ...i.profiles }; o.signingCertificate = i.certificate ?? 'Apple Distribution'; } return o; } // --- capabilities: App ID vs config vs the iOS project's entitlements /** Entitlement keys (*.entitlements) -> the App ID capability they need. In-App Purchase has no entitlement. */ export const ENTITLEMENT_CAPABILITIES: Record = { 'aps-environment': 'PUSH_NOTIFICATIONS', 'com.apple.developer.aps-environment': 'PUSH_NOTIFICATIONS', 'com.apple.developer.game-center': 'GAME_CENTER', 'com.apple.developer.associated-domains': 'ASSOCIATED_DOMAINS', 'com.apple.developer.applesignin': 'APPLE_ID_AUTH', 'com.apple.developer.icloud-container-identifiers': 'ICLOUD', 'com.apple.developer.icloud-services': 'ICLOUD', 'com.apple.developer.ubiquity-kvstore-identifier': 'ICLOUD', 'com.apple.developer.ubiquity-container-identifiers': 'ICLOUD', 'com.apple.security.application-groups': 'APP_GROUPS', 'com.apple.developer.healthkit': 'HEALTHKIT', 'com.apple.developer.homekit': 'HOMEKIT', 'com.apple.developer.in-app-payments': 'APPLE_PAY', 'com.apple.developer.pass-type-identifiers': 'WALLET', 'com.apple.developer.siri': 'SIRIKIT', 'com.apple.developer.networking.networkextension': 'NETWORK_EXTENSIONS', 'com.apple.developer.networking.vpn.api': 'PERSONAL_VPN', 'com.apple.developer.nfc.readersession.formats': 'NFC_TAG_READING', 'com.apple.developer.networking.wifi-info': 'ACCESS_WIFI_INFORMATION', 'com.apple.developer.default-data-protection': 'DATA_PROTECTION', 'com.apple.developer.ClassKit-environment': 'CLASSKIT', 'com.apple.developer.authentication-services.autofill-credential-provider': 'AUTOFILL_CREDENTIAL_PROVIDER', 'com.apple.developer.networking.multipath': 'MULTIPATH', 'com.apple.developer.networking.HotspotConfiguration': 'HOT_SPOT', 'inter-app-audio': 'INTER_APP_AUDIO', 'com.apple.external-accessory.wireless-configuration': 'WIRELESS_ACCESSORY_CONFIGURATION', 'com.apple.developer.coremedia.hls.low-latency': 'COREMEDIA_HLS_LOW_LATENCY', 'com.apple.developer.user-management': 'USER_MANAGEMENT', }; /** Top-level entitlement keys switched on in an .entitlements plist (a key whose value is is off). */ export function entitlementKeys(xml: string): string[] { const body = xml.replace(//g, ''); return [...body.matchAll(/\s*([^<]+?)\s*<\/key>\s*()?/g)].filter(m => !m[2]).map(m => m[1]!); } export interface ProjectEntitlements { file: string; caps: { cap: CapabilityType; key: string }[]; referenced: boolean | null } export function projectEntitlements(file: string, xml: string, referenced: boolean | null): ProjectEntitlements { const caps: { cap: CapabilityType; key: string }[] = []; for (const key of entitlementKeys(xml)) { const cap = ENTITLEMENT_CAPABILITIES[key]; if (cap && !caps.some(c => c.cap === cap)) caps.push({ cap, key }); } return { file, caps, referenced }; } export interface CapMismatch { cap: string; missingIn: 'app-id' | 'config' | 'project' | 'build'; msg: string; fix: string } /** Compares the three places a capability has to be: the App ID (Certificates, Identifiers & Profiles), the config's * apple.signing.capabilities and the iOS project's entitlements. `appId` null = not read; `project` null = no iOS * project found (not compared). Names exactly what is missing where. */ export interface CheckoutInfo { branch: string | null; /** other local branches / worktrees with an .entitlements file */ elsewhere: string[] } export interface BuildEntitlements { number: string; file: string; caps: { cap: CapabilityType; key: string }[]; via: 'codesign' | 'mach-o' } /** The capability-relevant entitlement keys of a built app, as projectEntitlements. */ export const buildEntitlements = (number: string, file: string, xml: string, via: BuildEntitlements['via']): BuildEntitlements => ({ number, file, caps: projectEntitlements(file, xml, null).caps, via }); /** The embedded entitlements plists of a Mach-O (thin or fat): the code signature's entitlements blob (magic * 0xfade7171, big-endian length, XML plist). Used when codesign is not available. */ export function machoEntitlements(bin: Buffer): string[] { const out: string[] = []; const magic = Buffer.from([0xfa, 0xde, 0x71, 0x71]); for (let i = bin.indexOf(magic); i >= 0; i = bin.indexOf(magic, i + 4)) { if (i + 8 > bin.length) break; const len = bin.readUInt32BE(i + 4); if (len < 8 || i + len > bin.length) continue; const xml = bin.subarray(i + 8, i + len).toString('utf8'); if (/^\s*(<\?xml|(); for (const p of i.project ?? []) for (const c of p.caps) if (!projCaps.has(c.cap)) projCaps.set(c.cap, { file: p.file, key: c.key }); const needsEntitlement = new Set(Object.values(ENTITLEMENT_CAPABILITIES)); if (i.appId) { for (const c of i.config) if (!has.has(c)) out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in apple.signing.capabilities but not on the App ID`, fix: 'storebound apple signing ensure --dry, then without --dry' }); for (const [c, w] of projCaps) if (!has.has(c) && !i.config.includes(c)) out.push({ cap: c, missingIn: 'app-id', msg: `${c} is in ${w.file} (${w.key}) but not on the App ID: the archive's profile will not allow it`, fix: `add '${c}' to apple.signing.capabilities, then storebound apple signing ensure` }); } for (const [c, w] of projCaps) if (!i.config.includes(c)) out.push({ cap: c, missingIn: 'config', msg: `${c} is in ${w.file} (${w.key}) but not in apple.signing.capabilities`, fix: `add '${c}' to apple.signing.capabilities so signing ensure keeps the App ID in step` }); if (i.project) { for (const c of i.config) if (needsEntitlement.has(c) && !projCaps.has(c)) { const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0]; // A warning about this checkout only: native changes often live on a release branch (OTA fingerprint), so it // never claims the build lacks them. The built IPA, when there is one, is checked below. const where = i.checkout?.branch ? ` on branch ${i.checkout.branch}` : ''; const other = i.checkout?.elsewhere.length ? `; an .entitlements file is on ${i.checkout.elsewhere.join(', ')}` : ''; const built = i.build ? (i.build.caps.some(x => x.cap === c) ? `; uploaded build ${i.build.number} has it` : '') : `; ${NO_BUILD_API}`; out.push({ cap: c, missingIn: 'project', msg: `${c}: this checkout's iOS project${where} has no entitlements for ${key} (built from another branch?)${other}${built}`, fix: i.checkout?.elsewhere.length ? `nothing if you build from ${i.checkout.elsewhere[0]}; otherwise add ${key} to the app target's .entitlements and set CODE_SIGN_ENTITLEMENTS` : `if this branch is the one you build: add ${key} to the app target's .entitlements (Xcode > Signing & Capabilities > + ${c.toLowerCase().replace(/_/g, ' ')}) and set CODE_SIGN_ENTITLEMENTS` }); } for (const p of i.project) if (p.referenced === false && p.caps.length) out.push({ cap: p.caps.map(c => c.cap).join(', '), missingIn: 'project', msg: `${p.file} is not referenced by CODE_SIGN_ENTITLEMENTS in the Xcode project: builds ignore it`, fix: `set CODE_SIGN_ENTITLEMENTS = ${p.file.split('/').slice(-2).join('/')} for the app target (Release and Debug)` }); } if (i.build) { const b = i.build, bc = new Set(b.caps.map(x => x.cap as string)); for (const c of i.config) if (needsEntitlement.has(c) && !bc.has(c)) { const key = Object.entries(ENTITLEMENT_CAPABILITIES).find(([, v]) => v === c)![0]; out.push({ cap: c, missingIn: 'build', msg: `${c} is in apple.signing.capabilities but uploaded build ${b.number} (${b.file}) has no ${key} entitlement`, fix: `build from the branch that has the entitlements, or add ${key} to the app target's .entitlements, then a new build` }); } if (i.appId) for (const x of b.caps) if (!has.has(x.cap)) out.push({ cap: x.cap, missingIn: 'app-id', msg: `uploaded build ${b.number} has ${x.key} but ${x.cap} is not on the App ID`, fix: `add '${x.cap}' to apple.signing.capabilities, then storebound apple signing ensure` }); } return out; } /** Reads the App ID's capabilities until every wanted one is there (App Store Connect lags right after a write) or the * time is up. `missing` empty = all there. */ export async function verifyCapabilities(read: () => Promise, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise; onWait?: (missing: string[], n: number) => void } = {}): Promise<{ have: string[]; missing: string[]; reads: number }> { const tries = o.tries ?? 5, waitMs = o.waitMs ?? 3000, deadline = Date.now() + (o.timeoutMs ?? 45_000); const sleep = o.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms))); let have: string[] = [], missing = want, reads = 0; for (let n = 0; n < tries; n++) { if (n) { o.onWait?.(missing, n); if (Date.now() + waitMs * n > deadline) break; await sleep(waitMs * n); } have = await read(); reads++; missing = want.filter(w => !have.includes(w)); if (!missing.length) break; } return { have, missing, reads }; } /** Provisioning profiles storebound made ("storebound ..."), not in `inUse`: unused leftovers whose * deletion is the owner's call. */ export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set): ApiProfile[] => list.filter(p => /^storebound /.test(p.attributes.name ?? '') && !inUse.has(p.id)); export const deleteProfileCmd = (p: ApiProfile): string => `storebound api apple profiles_deleteInstance --param id=${p.id} --yes # "${p.attributes.name}" (in a terminal; asks you to type the operation id)`; /** What `signing ensure` writes: automatic signing only adds capabilities (Xcode owns certificates and profiles). */ export function ensureScope(style: 'automatic' | 'manual' | undefined): { certificates: boolean; profiles: boolean; why: string } { return (style ?? 'automatic') === 'automatic' ? { certificates: false, profiles: false, why: 'automatic signing: ensure only keeps the App ID and its capabilities in step; certificates and provisioning profiles are Xcode\'s (xcodebuild -allowProvisioningUpdates with the API key makes them at archive time)' } : { certificates: true, profiles: true, why: 'manual signing: ensure also keeps the distribution certificate and an App Store profile per bundle id' }; }