// Code signing through the App Store Connect API: bundle id capabilities, the distribution certificate and the // provisioning profiles, so `apple build` can sign without anyone opening Xcode's Accounts settings. // storebound apple signing status [--json] read-only: capabilities, certificates (with / without a // private key on this machine), profiles, what is installed // storebound apple signing ensure [--dry] [--yes] [--keychain [path]] [--no-install] [--json] // adds missing capabilities (apple.signing.capabilities) and reads them back (fails when one did not stick). // style automatic (the default) stops there: Xcode owns certificates and profiles. style manual also // creates a distribution certificate when this machine // holds no key for any valid one (RSA 2048 key generated here, kept 0600 in .storebound/signing/, never printed; // --keychain also imports key + certificate into the login keychain or the given one), creates or reuses an // App Store profile per bundle id (apple.bundleId + apple.signing.extraBundleIds) and installs it for Xcode. // Every write is shown first (--dry shows only that) and asks once before anything is created. Developer ID // certificates need the Account Holder: the API's 403 is explained. Pure planning: cli/lib/signing.ts. import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { basename, dirname, join, relative, resolve as resolvePath } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { buildCsr, csrContent, newSigningKey, spkiOfKey } from '../lib/csr'; import { done, log, printJson, warn } from '../lib/log'; import { type ApiCert, type ApiProfile, type AppleSigning, type BuildEntitlements, buildEntitlements, type CapabilityType, type CapMismatch, type CheckoutInfo, ENTITLEMENT_CAPABILITIES, machoEntitlements, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType, compareCapabilities, deleteProfileCmd, ensureScope, explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, type ProjectEntitlements, projectEntitlements, readProfiles, unusedStoreboundProfiles, verifyCapabilities, } from '../lib/signing'; import { isBuildCopyPath } from '../lib/privacy-manifest'; import { exportedIpas, ipaInfo, scanXcode } from '../lib/xcode'; import { parsePlist } from '../lib/plist'; import { zipEntries, zipRead } from '../lib/zip'; import type { ShipConfig } from '../lib/config'; import { textTable } from '../lib/table'; import { type Asc, ascClient, type J } from './asc'; import { describeReq, execute, type Req } from './jsonapi'; import { createOnce, isAmbiguous, upsertTiming } from '../lib/upsert'; import * as rt from '../lib/rt'; const TAG = 'signing'; const USAGE = 'usage: storebound apple signing status | ensure [--dry] [--keychain [path]] [--no-install]'; export const signingDir = (root: string): string => join(root, '.storebound', 'signing'); /** SPKIs of the private keys in .storebound/signing (file name = certificate id once created). */ function localKeys(root: string): Map { const dir = signingDir(root); const out = new Map(); if (!existsSync(dir)) return out; for (const f of readdirSync(dir).filter(f => f.endsWith('.key'))) { try { out.set(spkiOfKey(readFileSync(join(dir, f), 'utf8')), f); } catch { warn(TAG, `${f}: not a private key, ignored`); } } return out; } /** SHA-1s of code signing identities in the keychain search list (macOS; empty elsewhere). Read-only. */ function keychainIdentities(): Set { if (process.platform !== 'darwin' || !rt.which('security')) return new Set(); const r = rt.spawnSync(['security', 'find-identity', '-v', '-p', 'codesigning'], { stdout: 'pipe', stderr: 'pipe' }); return new Set(parseIdentities(r.stdout.toString()).map(i => i.sha1)); } export async function findBundleId(c: Asc, identifier: string): Promise { const r = await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&include=bundleIdCapabilities&limit=200`); const hit = (r.data as J[]).find(b => b.attributes.identifier === identifier) ?? null; if (hit) hit.capabilities = (r.included ?? []).filter((x: J) => x.type === 'bundleIdCapabilities' && (hit.relationships?.bundleIdCapabilities?.data ?? []).some((d: J) => d.id === x.id)) .map((x: J) => x.attributes.capabilityType as string); return hit; } /** The iOS project's .entitlements files (next to each .xcodeproj, build copies skipped) with the capabilities they * need and whether the project references them (CODE_SIGN_ENTITLEMENTS). null when there is no Xcode project. */ export function readProjectEntitlements(root: string): ProjectEntitlements[] | null { const projects = scanXcode(root).projects.filter(p => !isBuildCopyPath(p) && !/(^|\/)Pods\.xcodeproj$/.test(p)); if (!projects.length) return null; const out: ProjectEntitlements[] = []; for (const proj of projects) { let pbx = ''; try { pbx = readFileSync(join(root, proj, 'project.pbxproj'), 'utf8'); } catch { /* unreadable */ } const refs = [...pbx.matchAll(/CODE_SIGN_ENTITLEMENTS\s*=\s*"?([^";]+)"?;/g)].map(m => m[1]!.trim().replace(/^\$\(SRCROOT\)\//, '')); const walk = (dir: string, d: number): void => { let names: string[] = []; try { names = readdirSync(dir); } catch { return; } for (const n of names) { const f = join(dir, n); if (n.endsWith('.entitlements')) { const rel = relative(root, f); if (isBuildCopyPath(rel) || out.some(o => o.file === rel)) continue; out.push(projectEntitlements(rel, readFileSync(f, 'utf8'), pbx ? refs.some(r => f.endsWith('/' + r)) : null)); } else if (d > 0 && !n.startsWith('.') && !['node_modules', 'Pods', 'build', 'DerivedData'].includes(n) && !n.endsWith('.xcodeproj')) { try { if (statSync(f).isDirectory()) walk(f, d - 1); } catch { /* unreadable */ } } } }; walk(dirname(join(root, proj)), 3); } return out; } const git = (root: string, args: string[]): string | null => { try { const r = rt.spawnSync(['git', '-C', root, ...args], { stdout: 'pipe', stderr: 'pipe' }); return r.success ? r.stdout.toString() : null; } catch { return null; } }; /** The current git branch and the other local branches (and the worktrees that have them out) whose tree has an * .entitlements file (build copies skipped). null outside a git repository. */ export function checkoutInfo(root: string): CheckoutInfo | null { const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']); if (head === null) return null; const branch = head.trim() === 'HEAD' ? null : head.trim(); const worktrees = new Map(); let wt = ''; for (const l of (git(root, ['worktree', 'list', '--porcelain']) ?? '').split('\n')) { if (l.startsWith('worktree ')) wt = l.slice(9); else if (l.startsWith('branch refs/heads/')) worktrees.set(l.slice(18), wt); } const elsewhere: string[] = []; const branches = (git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']) ?? '').split('\n').filter(Boolean).filter(b => b !== branch).slice(0, 100); for (const b of branches) { const files = (git(root, ['ls-tree', '-r', '--name-only', b]) ?? '').split('\n').filter(f => f.endsWith('.entitlements') && !isBuildCopyPath(f)); if (!files.length) continue; const w = worktrees.get(b); elsewhere.push(`branch ${b} (${files.slice(0, 2).join(', ')}${files.length > 2 ? ', ...' : ''}${w ? `; worktree ${w}` : ''})`); } return { branch, elsewhere }; } /** The real entitlements of the newest built IPA (highest build number, newest file per number): codesign on macOS, * else the Mach-O signature's entitlements blob. null when there is no IPA or it cannot be read. */ export function builtIpaEntitlements(root: string, config: ShipConfig): BuildEntitlements | null { const dir = join(resolvePath(root, config.apple?.build?.out ?? 'build/ios'), 'export'); const ipas: { file: string; build: string; exe: string }[] = []; for (const f of exportedIpas(dir)) { try { const e = zipEntries(f).find(x => /^Payload\/[^/]+\.app\/Info\.plist$/.test(x.name))!; const p = parsePlist(zipRead(f, e)) as Record; ipas.push({ file: f, build: ipaInfo(f).build, exe: `${e.name.slice(0, -'Info.plist'.length)}${String(p.CFBundleExecutable ?? '')}` }); } catch { /* not an app archive */ } } // exportedIpas is newest first; a stable sort by build number keeps the newest file of each number first ipas.sort((x, y) => y.build.localeCompare(x.build, 'en', { numeric: true })); const pick = ipas[0]; if (!pick) return null; const entry = zipEntries(pick.file).find(x => x.name === pick.exe); if (!entry) return null; const bin = zipRead(pick.file, entry); const rel = relative(root, pick.file); if (process.platform === 'darwin' && rt.which('codesign')) { const tmp = mkdtempSync(join(tmpdir(), 'storebound-ent-')); try { const f = join(tmp, basename(pick.exe)); writeFileSync(f, bin); const r = rt.spawnSync(['codesign', '-d', '--entitlements', ':-', f], { stdout: 'pipe', stderr: 'pipe' }); const xml = r.stdout.toString(); if (r.success && / { const bundleId = config.apple!.bundleId; const b = await findBundleId(c, bundleId); const appId = b ? (b.capabilities as string[]) : null; const cfg = capabilitySpecs(config.apple?.signing).map(x => x.type); const project = readProjectEntitlements(root); let build: BuildEntitlements | null = null; try { build = builtIpaEntitlements(root, config); } catch { /* unreadable IPA: compared without it */ } const checkout = project && cfg.some(x => Object.values(ENTITLEMENT_CAPABILITIES).includes(x as CapabilityType)) && !project.some(p => p.caps.length) ? checkoutInfo(root) : null; return { bundleId, appId, config: cfg, project, checkout, build, mismatches: compareCapabilities({ appId, config: cfg, project, checkout, build }) }; } export function printCapabilityReport(r: CapabilityReport, tag: string): void { const proj = r.project === null ? 'no Xcode project found' : r.project.length ? r.project.map(p => `${p.file}: ${p.caps.map(c => c.cap).join(', ') || 'none'}${p.referenced === false ? ' (NOT referenced by CODE_SIGN_ENTITLEMENTS)' : ''}`).join('; ') : 'no .entitlements file'; const built = r.build ? `uploaded build ${r.build.number} (${r.build.file}, via ${r.build.via}): ${r.build.caps.map(c => c.cap).join(', ') || 'none'}` : 'no local IPA (the App Store Connect API does not expose a build\'s entitlements)'; log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj} | ${built}`); for (const m of r.mismatches) warn(tag, `${m.msg}. Fix: ${m.fix}`); if (!r.mismatches.length) log(tag, 'capabilities match (App ID, config, project)'); } const profilesOf = (c: Asc, bundleIdId: string, type: ProfileType): Promise => readProfiles(p => c.all(p), bundleIdId, type); const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []); function install(p: ApiProfile): string[] { const content = p.attributes.profileContent; if (!content || !p.attributes.uuid) throw new Error(`profile ${p.attributes.name}: no content to install`); const out: string[] = []; for (const d of profileDirs(homedir())) { mkdirSync(d, { recursive: true }); const f = join(d, `${p.attributes.uuid}.mobileprovision`); writeFileSync(f, Buffer.from(content, 'base64')); out.push(f); } return out; } function keychainImport(keyFile: string, cerFile: string, keychain: string | null): void { if (process.platform !== 'darwin') throw new Error('--keychain: macOS only (on other systems keep the key in .storebound/signing)'); const kc = keychain ? ['-k', keychain] : []; for (const cmd of [['security', 'import', keyFile, ...kc, '-t', 'priv', '-f', 'openssl', '-T', '/usr/bin/codesign', '-T', '/usr/bin/productbuild'], ['security', 'import', cerFile, ...kc, '-t', 'cert']]) { const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe' }); const msg = (r.stdout.toString() + r.stderr.toString()).trim(); if (r.exitCode !== 0 && !/already exists/i.test(msg)) throw new Error(`security import failed: ${msg.slice(0, 300)}`); } log(TAG, `key and certificate imported into ${keychain ?? 'the default keychain'}`); } export interface SigningState { bundleId: string; teamId: string | null; registered: boolean; capabilities: { present: string[]; missing: string[]; extra: string[] }; certificate: { type: CertType; chosen: string | null; why: string; onTeam: number; withKey: number; limit: number | null }; profiles: { bundleId: string; name: string | null; uuid: string | null; state: string; installed: boolean; why: string }[]; } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const sub = a._[0]; if (sub !== 'status' && sub !== 'ensure') throw new Error(USAGE); const bundleId = need(config.apple?.bundleId, 'apple.bundleId'); const s: AppleSigning = config.apple?.signing ?? {}; const certType: CertType = s.certificateType ?? 'DISTRIBUTION'; const profileType: ProfileType = s.profileType ?? 'IOS_APP_STORE'; const dry = a.has('dry'); const json = a.has('json'); const style = s.style ?? 'automatic'; const scope = ensureScope(style); const c = await ascClient(root, bundleId, config.apple?.appId); // --- read everything first (read-only) const ids = [bundleId, ...(s.extraBundleIds ?? [])]; const bundles = new Map(); for (const id of ids) bundles.set(id, await findBundleId(c, id)); const main = bundles.get(bundleId); const capPlan = main ? planCapabilities(main.id, main.capabilities ?? [], capabilitySpecs(s)) : { create: [], present: [], extra: [] }; const certs: ApiCert[] = await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`); const keys = localKeys(root); const identities = keychainIdentities(); const hasKey = (x: ApiCert): boolean => { const content = x.attributes.certificateContent; if (!content) return false; const spki = certSpki(content); return (!!spki && keys.has(spki)) || identities.has(certSha1(content)); }; const choice = pickCertificate(certs, certType, hasKey); const profiles = new Map | null }>(); for (const id of ids) { const b = bundles.get(id); if (!b) { profiles.set(id, { list: [], plan: null }); continue; } const list = await profilesOf(c, b.id, profileType); profiles.set(id, { list, plan: choice.cert ? planProfile(list, { bundleId: id, bundleIdId: b.id, certId: choice.cert.id, type: profileType }) : null }); } const state: SigningState = { bundleId, teamId: s.teamId ?? main?.attributes?.seedId ?? null, registered: !!main, capabilities: { present: capPlan.present, missing: capabilitySpecs(s).filter(w => !capPlan.present.includes(w.type)).map(w => w.type), extra: capPlan.extra }, certificate: { type: certType, chosen: choice.cert?.attributes.name ?? choice.cert?.attributes.displayName ?? null, why: choice.why, onTeam: certs.filter(x => Date.parse(x.attributes.expirationDate ?? '') > Date.now()).length, withKey: choice.usable.length, limit: CERT_LIMIT[certType] ?? null }, profiles: ids.map(id => { const p = profiles.get(id)!; if (!bundles.get(id)) return { bundleId: id, name: null, uuid: null, state: 'bundle id not registered', installed: false, why: 'register it first (storebound apple bundle for the app; extensions in Certificates, Identifiers & Profiles)' }; const r = p.plan?.reuse; const managed = p.list.find(x => x.managed && x.attributes.profileState === 'ACTIVE'); if (style === 'automatic') return managed ? { bundleId: id, name: managed.attributes.name ?? null, uuid: managed.attributes.uuid ?? null, state: 'Xcode-managed', installed: installedAt(managed.attributes.uuid).length > 0, why: `automatic signing: xcodebuild -allowProvisioningUpdates keeps it (expires ${managed.attributes.expirationDate?.slice(0, 10) ?? '?'}; renewed with the App ID's current capabilities when they change)` } : { bundleId: id, name: null, uuid: null, state: 'Xcode-managed', installed: false, why: 'automatic signing: xcodebuild -allowProvisioningUpdates makes it at archive time' }; return { bundleId: id, name: r?.attributes.name ?? null, uuid: r?.attributes.uuid ?? null, state: r ? 'ACTIVE' : 'needs a new profile', installed: installedAt(r?.attributes.uuid).length > 0, why: p.plan?.why ?? 'waits for a usable certificate' }; }), }; // storebound-made profiles that nothing uses (automatic signing: all of them; manual: all but the reused one) const inUse = new Set(style === 'manual' ? [...profiles.values()].map(p => p.plan?.reuse?.id).filter((x): x is string => !!x) : []); const unused = [...profiles.values()].flatMap(p => unusedStoreboundProfiles(p.list, inUse)); const cleanup = (): void => { if (!unused.length) return; log(TAG, `${unused.length} provisioning profile(s) storebound made that nothing uses${style === 'automatic' ? ' (automatic signing never needs them)' : ''}: ${unused.map(p => `"${p.attributes.name}"`).join(', ')}`); log(TAG, 'deleting them is your call; not run by storebound:'); for (const p of unused) log(TAG, ` ${deleteProfileCmd(p)}`); }; const report = await capabilityReport(c, config, root); if (sub === 'status') { if (json) { printJson({ ...state, style, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; } log(TAG, `${bundleId}: ${main ? `registered, team ${state.teamId ?? '?'}` : 'NOT registered (storebound apple bundle)'}`); log(TAG, `capabilities: ${state.capabilities.present.join(', ') || 'none configured'}${state.capabilities.missing.length ? `; missing ${state.capabilities.missing.join(', ')}` : ''}${state.capabilities.extra.length ? `; also on the bundle id: ${state.capabilities.extra.join(', ')}` : ''}`); log(TAG, `certificate ${certType}: ${choice.why} (${state.certificate.onTeam} valid on the team${state.certificate.limit ? `, limit ${state.certificate.limit}` : ''})`); for (const x of choice.expiring) warn(TAG, `${x.attributes.name ?? x.id} expires ${x.attributes.expirationDate?.slice(0, 10)}: make a new one soon (storebound apple signing ensure)`); console.log(textTable(['bundle id', 'profile', 'state', 'installed', ''], state.profiles.map(p => [p.bundleId, p.name ?? '', p.state, p.installed ? 'yes' : 'no', p.why]))); printCapabilityReport(report, TAG); cleanup(); done(TAG, 'status read'); return; } // --- ensure: plan, show, ask once, then write if (!main) throw new Error(`bundle id ${bundleId} is not registered: storebound apple bundle --dry, then without --dry`); const missingExtra = ids.filter(id => !bundles.get(id)); if (missingExtra.length) throw new Error(`not registered: ${missingExtra.join(', ')} (register extension bundle ids in Certificates, Identifiers & Profiles first)`); const needCert = scope.certificates && !choice.cert; const plan: string[] = [ ...capPlan.create.map(r => describeReq(r)), ...(needCert ? [describeReq(certificateReq(certType, ''))] : []), ...(!scope.profiles ? [] : ids.flatMap(id => { const p = profiles.get(id)!.plan; return !p || p.create ? [`POST /v1/profiles {name=..., profileType="${profileType}"} # ${id}: ${p?.why ?? 'with the new certificate'}`] : []; })), ]; log(TAG, scope.why); if (needCert && state.certificate.limit && state.certificate.onTeam >= state.certificate.limit) warn(TAG, `the team already has ${state.certificate.onTeam} valid ${certType} certificates (limit ${state.certificate.limit}): Apple will refuse a new one until one is revoked (owner decision, developer.apple.com > Certificates)`); if (json && dry) { printJson({ ...state, style, scope, plan, capabilityCheck: report, unusedProfiles: unused.map(p => ({ id: p.id, name: p.attributes.name })) }); return; } for (const line of plan) log(TAG, `${dry ? 'would ' : ''}${line}`); if (!plan.length) log(TAG, 'nothing to create on App Store Connect'); if (dry) { if (scope.profiles) for (const p of state.profiles) if (p.uuid && !p.installed && !a.has('no-install')) log(TAG, `would install profile ${p.name} (${p.uuid}) for Xcode`); if (capPlan.create.length) log(TAG, `would read the App ID's capabilities back after the write and fail if ${capPlan.create.map(r => (r.body as J).data.attributes.capabilityType).join(' / ')} is not there`); printCapabilityReport(report, TAG); cleanup(); done(TAG, `signing checked (dry run): ${plan.length} write(s)`); return; } if (plan.length && !(await confirm(ctx, `Create ${plan.length} item(s) on the Apple Developer team (${[capPlan.create.length && `${capPlan.create.length} capabilities`, needCert && `a ${certType} certificate`, plan.length - capPlan.create.length - (needCert ? 1 : 0) && 'provisioning profiles'].filter(Boolean).join(', ')})?`))) return; const responses = await execute(c.asc, capPlan.create, { dry: false, tag: TAG }); if (capPlan.create.length) await checkCapabilitiesStuck(c, bundleId, capabilitySpecs(s).map(w => w.type), responses); if (!scope.profiles) { printCapabilityReport(await capabilityReport(c, config, root), TAG); cleanup(); if (json) printJson({ ...state, style, scope, plan, done: true }); done(TAG, `capabilities in step; ${scope.why.split(':')[0]}: nothing else to create`); return; } let cert = choice.cert; if (needCert) { const dir = signingDir(root); mkdirSync(dir, { recursive: true, mode: 0o700 }); const { key } = newSigningKey(); const pending = join(dir, `pending-${Date.now()}.key`); writeFileSync(pending, key.export({ type: 'pkcs1', format: 'pem' }).toString(), { mode: 0o600 }); chmodSync(pending, 0o600); let created: J; try { const csr = buildCsr(key, { commonName: `storebound ${config.app.name}`.slice(0, 64) }); created = (await c.asc('POST', '/v1/certificates', certificateReq(certType, csrContent(csr)).body)).data; } catch (e) { if (isAmbiguous(e)) { // the certificate may exist: it is the one whose public key is this pending key's. The key stays on disk either // way (a later run finds the certificate through it), so no certificate is ever orphaned or made twice. const mine = spkiOfKey(readFileSync(pending, 'utf8')); let found: J | undefined; for (let i = 0; i < 4 && !found; i++) { if (i) await upsertTiming.sleep(upsertTiming.waitMs * i); found = (await c.all(`/v1/certificates?filter[certificateType]=${certType}&limit=200`)).find((x: ApiCert) => !!x.attributes.certificateContent && certSpki(x.attributes.certificateContent) === mine); } if (!found) throw new Error(`${(e as Error).message}\n\nThe certificate request got no clear answer and App Store Connect does not list a certificate for the new key (yet). The key is kept in ${pending.replace(root + '/', '')}; run \`storebound apple signing ensure\` again in a minute: it finds the certificate through that key instead of making another one.`); created = found; log(TAG, 'the certificate request went through (found by its public key)'); } else { rmSync(pending, { force: true }); const why = explainCertError(e, certType); throw new Error(why ? `${(e as Error).message}\n\n${why}` : (e as Error).message); } } const keyFile = join(dir, `${created.id}.key`), cerFile = join(dir, `${created.id}.cer`); renameSync(pending, keyFile); writeFileSync(cerFile, Buffer.from(created.attributes.certificateContent, 'base64'), { mode: 0o600 }); log(TAG, `certificate ${created.attributes.name ?? created.id} created (expires ${String(created.attributes.expirationDate).slice(0, 10)}); key and certificate in .storebound/signing/ (gitignored, back them up)`); if (a.has('keychain')) keychainImport(keyFile, cerFile, a.get('keychain') === 'true' ? null : a.get('keychain')!); else warn(TAG, 'xcodebuild signs with identities from the keychain: re-run with --keychain to import it there (or import the .key + .cer into the login keychain with `security import`)'); cert = created; } for (const id of ids) { const b = bundles.get(id)!; const list = profiles.get(id)!.list; const p = planProfile(list, { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); let prof: ApiProfile; if (p.create) { // an unclear answer is checked by reading the bundle id's profiles again (one active profile with this // certificate is reused), never re-sent blindly const r = (await createOnce({ what: `${id} profile`, tag: TAG, find: async () => { const again = planProfile(await profilesOf(c, b.id, profileType), { bundleId: id, bundleIdId: b.id, certId: cert!.id, type: profileType }); return again.reuse ? { id: again.reuse.id, attributes: (await c.asc('GET', `/v1/profiles/${again.reuse.id}`)).data.attributes } : null; }, create: async () => (await c.asc('POST', p.create!.path, p.create!.body)).data })).value; prof = { id: r.id, attributes: r.attributes }; log(TAG, `${id}: profile "${prof.attributes.name}" created (${prof.attributes.uuid})`); } else { prof = p.reuse!; if (!prof.attributes.profileContent) prof = { ...prof, attributes: (await c.asc('GET', `/v1/profiles/${prof.id}`)).data.attributes }; log(TAG, `${id}: ${p.why}`); } if (!a.has('no-install')) for (const f of install(prof)) log(TAG, `${id}: installed ${f.replace(homedir(), '~')}`); } cleanup(); if (json) printJson({ ...state, plan, done: true }); done(TAG, 'signing ready (storebound apple build --dry shows the ExportOptions.plist it will use)'); } /** After a capability write: read the App ID back (App Store Connect lags a little) and fail, with the API's answers, * when a wanted capability is still not there. Seen live: an add that "succeeded" while the App ID kept only * IN_APP_PURCHASE. */ export async function checkCapabilitiesStuck(c: Asc, bundleId: string, want: string[], responses: unknown[], o: { waitMs?: number; timeoutMs?: number } = {}): Promise { const r = await verifyCapabilities(async () => ((await findBundleId(c, bundleId))?.capabilities as string[] | undefined) ?? [], want, { waitMs: o.waitMs ?? upsertTiming.waitMs, timeoutMs: o.timeoutMs, sleep: upsertTiming.sleep, onWait: (m, n) => log(TAG, `read-back ${n}: ${m.join(', ')} not on the App ID yet, checking again`) }); if (!r.missing.length) { log(TAG, `read back: the App ID has ${want.join(', ')}`); return; } throw new Error(`capability write did not stick: ${bundleId} still lacks ${r.missing.join(', ')} after ${r.reads} read(s) (it has: ${r.have.join(', ') || 'none'}).\n` + `API answers to the writes: ${JSON.stringify(responses).slice(0, 1500)}\n` + 'Check the bundle id in the config (apple.bundleId), that the API key has the Admin role, and add the capability in ' + 'developer.apple.com > Certificates, Identifiers & Profiles > Identifiers if the API keeps refusing it; then run storebound apple signing status.'); }