apple signing ensure with automatic signing only adds capabilities (no profiles or certificates; the dry run says so) and lists unused storebound profiles with the exact delete command, never run; every capability write is read back (lag retries, timeout) and fails with the API answers when one did not stick; apple status, signing status and validate compare the App ID, apple.signing.capabilities and the iOS project's entitlements (CODE_SIGN_ENTITLEMENTS too) and name what is missing where; api writes outside a terminal say TTY by design and name the dedicated command; pitfalls 64-66; tests

This commit is contained in:
CRTkafa 2026-10-14 19:10:01 +08:00
parent 357319a775
commit daae3715bf
12 changed files with 463 additions and 23 deletions

View file

@ -2,6 +2,18 @@
## Unreleased
- **`apple signing ensure` with automatic signing creates no profiles or certificates.** With `signing.style:
automatic` (the default) it only adds missing capabilities to the App ID; Xcode owns certificates and profiles, and
the dry run says so. Provisioning profiles storebound made that nothing uses are listed by name with the exact
delete command (never run by storebound).
- **Capability writes are read back.** After adding a capability, `ensure` reads the App ID again (a few tries,
about 45 s at most) and fails with the API's answers when one is missing, instead of reporting success.
- **Capabilities compared in three places.** `apple status`, `apple signing status` / `ensure --dry` and `validate`
compare the App ID, `apple.signing.capabilities` and the iOS project's `.entitlements` (push, Game Center,
associated domains, Sign in with Apple, iCloud, app groups, ...; also an entitlements file not set in
CODE_SIGN_ENTITLEMENTS) and name what is missing where.
- **`storebound api` writes outside a terminal** say that they need an interactive terminal by design and name the
dedicated command for common operations (bundle id capabilities: `apple signing ensure`, IAPs, listing, ...).
- **`--json` prints only JSON on stdout.** Every command run with `--json` sends its log, step and table lines to
stderr (one place: `withJsonOutput` in cli/lib/log.ts, wrapped around every command); the last commands that printed
JSON with console.log use `printJson`. A test runs every command that reads `--json` against the fake stores and

View file

@ -13,7 +13,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Args } from './lib/args';
import { allOps, authFor, type BuiltRequest, buildRequest, resolveOp, send, sendAll } from './api/client';
import { decide, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
import { decide, dedicatedCommand, gateOf, ownerReason, resourceOf, type GateKind, type Store } from './api/gate';
import { APPLE_SPEC } from './api/apple.gen';
import { PLAY_APIS } from './api/play.gen';
import { WRAPPED } from './api/wrapped.gen';
@ -147,7 +147,9 @@ export async function run(args: Args): Promise<void> {
});
if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`);
if (decision.action === 'dry') {
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
const instead = op.method !== 'GET' ? dedicatedCommand(st, op.id) : null;
if (instead) console.error(`api: ${op.id} has a dedicated storebound command that runs without a terminal: ${instead}`);
if (json) printJson({ dryRun: true, operation: op.id, gate: kind, why: decision.why, ...(instead ? { instead } : {}), request: { method: req.method, url: req.url, body: typeof req.body === 'string' ? JSON.parse(req.body) : req.body ? `<${req.body.length} bytes>` : undefined } });
else console.log(`dry run (${decision.why}): nothing sent\n${kind === 'owner' ? `owner only: this ${ownerReason(st, op.id, op.method)}; sending also asks you to type the operation id\n` : ''}\n${describeRequest(req)}`);
return;
}

View file

@ -94,6 +94,28 @@ export function decide(g: GateInput): Decision {
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : 'writes are dry by default: add --yes (in a terminal) to send' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: writes through `storebound api` are only sent from one' };
if (!g.tty) return { action: 'dry', why: 'not an interactive terminal: by design, writes through `storebound api` are sent only from an interactive terminal (--yes alone is not enough)' };
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
}
/** storebound commands that do the common writes of a resource the safe way (dry run, read-back, idempotent):
* named when a raw `api` write is held, so an agent uses them instead of a terminal. First match wins. */
const DEDICATED: [Store, RegExp, string][] = [
['apple', /^bundleIdCapabilities_/, 'storebound apple signing ensure --dry, then without --dry (adds apple.signing.capabilities to the App ID and reads them back)'],
['apple', /^bundleIds_/, 'storebound apple bundle --dry, then without --dry'],
['apple', /^(certificates|profiles)_/, 'storebound apple signing ensure (manual signing; automatic signing leaves certificates and profiles to Xcode)'],
['apple', /^devices_/, 'storebound apple adhoc register --dry (adhoc-distribution skill)'],
['apple', /^(inAppPurchases|inAppPurchaseLocalizations|inAppPurchasePriceSchedules)/, 'storebound apple iap --dry'],
['apple', /^(subscriptions|subscriptionGroups|subscriptionLocalizations|subscriptionPrices)/, 'storebound apple subscriptions --dry'],
['apple', /^(appStoreVersionLocalizations|appInfoLocalizations)_/, 'storebound apple listing --dry'],
['apple', /^(appScreenshotSets|appScreenshots)_/, 'storebound apple screenshots --dry'],
['apple', /^betaGroups_|^betaTesters_/, 'storebound apple testflight --dry'],
['play', /\.(listings|details)\./, 'storebound play listing --dry'],
['play', /\.images\./, 'storebound play images --dry'],
['play', /\.(inappproducts|onetimeproducts)\./, 'storebound play products --dry'],
['play', /\.subscriptions\./, 'storebound play subscriptions --dry'],
];
export function dedicatedCommand(store: Store, opId: string): string | null {
return DEDICATED.find(([s, re]) => s === store && re.test(opId))?.[2] ?? null;
}

View file

@ -3,23 +3,29 @@
// 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), creates a distribution certificate when this machine
// 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, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { dirname, join, relative } 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, capabilitySpecs, CERT_LIMIT, certificateReq, certSha1, certSpki, type CertType,
explainCertError, parseIdentities, pickCertificate, planCapabilities, planProfile, profileDirs, type ProfileType, readProfiles,
type ApiCert, type ApiProfile, type AppleSigning, type CapMismatch, 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 { scanXcode } from '../lib/xcode';
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';
@ -57,6 +63,54 @@ export async function findBundleId(c: Asc, identifier: string): Promise<J | null
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;
}
export interface CapabilityReport { bundleId: string; appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null; mismatches: CapMismatch[] }
/** Read-only: the App ID's capabilities vs apple.signing.capabilities vs the iOS project's entitlements. */
export async function capabilityReport(c: Asc, config: ShipConfig, root: string): Promise<CapabilityReport> {
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);
return { bundleId, appId, config: cfg, project, mismatches: compareCapabilities({ appId, config: cfg, project }) };
}
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';
log(tag, `capabilities of ${r.bundleId}: App ID ${r.appId ? r.appId.join(', ') || 'none' : 'not registered'} | config ${r.config.join(', ') || 'none'} | project ${proj}`);
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<ApiProfile[]> => readProfiles(p => c.all(p), bundleIdId, type);
const installedAt = (uuid: string | undefined): string[] => (uuid ? profileDirs(homedir()).map(d => join(d, `${uuid}.mobileprovision`)).filter(existsSync) : []);
@ -103,6 +157,8 @@ export async function run(ctx: Ctx): Promise<void> {
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)
@ -138,21 +194,36 @@ export async function run(ctx: Ctx): Promise<void> {
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 (!r && managed && (s.style ?? 'automatic') === 'automatic')
return { 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) ?? '?'})` };
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); return; }
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;
}
@ -161,28 +232,40 @@ export async function run(ctx: Ctx): Promise<void> {
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 = !choice.cert;
const needCert = scope.certificates && !choice.cert;
const plan: string[] = [
...capPlan.create.map(r => describeReq(r)),
...(needCert ? [describeReq(certificateReq(certType, '<CSR of a new RSA 2048 key>'))] : []),
...ids.flatMap(id => {
...(!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, plan }); return; }
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) {
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 (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;
await execute(c.asc, capPlan.create, { dry: false, tag: TAG });
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);
@ -245,7 +328,21 @@ export async function run(ctx: Ctx): Promise<void> {
}
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<void> {
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.');
}

View file

@ -9,6 +9,8 @@
// Wait for App Review (polls with backoff: 1 min, x1.5, max 15 min):
// storebound apple status --wait-review [--version 1.2.0] [--timeout 48h]
// exit codes: 0 approved, 1 rejected, 7 timeout (2 = nothing in review)
// Also compares the App ID's capabilities, apple.signing.capabilities and the iOS project's entitlements and warns,
// naming what is missing where.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { log, printJson } from '../lib/log';
@ -16,6 +18,7 @@ import { classifyReviewState, nextPollDelay, parseDuration, WAIT_EXIT } from '..
import { EMPTY_DRAFT } from '../lib/review-submit';
import { type Asc, ascClient, type J } from './asc';
import { notCancellable, readItems, type SubItem } from './submission';
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
import * as rt from '../lib/rt';
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
@ -68,6 +71,8 @@ export async function run(ctx: Ctx): Promise<void> {
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
let caps: CapabilityReport | { error: string };
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
// a submission back from App Review: its items and their states, and how to resubmit it
const unresolved: { id: string; items: SubItem[] }[] = [];
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
@ -97,7 +102,7 @@ export async function run(ctx: Ctx): Promise<void> {
const v = pre.get(b.relationships?.preReleaseVersion?.data?.id ?? '');
if (v) buildVersions[b.id] = v;
}
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty] });
printJson({ app, versions, builds: builds.data, attachedBuilds, buildVersions, iaps, subs, phased, unresolved, emptyDrafts: [...empty], capabilityCheck: caps });
return;
}
@ -113,4 +118,6 @@ export async function run(ctx: Ctx): Promise<void> {
const newest = builds.data[0]?.attributes?.version;
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
}
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
else printCapabilityReport(caps, 'apple');
}

View file

@ -188,6 +188,8 @@
],
"apple status": [
"cancel-submission",
"keychain",
"no-install",
"timeout",
"version",
"wait-review"
@ -817,8 +819,10 @@
"include-drafts",
"issue",
"jobs",
"keychain",
"lang",
"no-health",
"no-install",
"no-local",
"no-scan",
"out",

View file

@ -199,3 +199,114 @@ export function exportOptions(i: ExportOptionsInput): Record<string, PlistValue>
}
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<string, CapabilityType> = {
'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 <false/> is off). */
export function entitlementKeys(xml: string): string[] {
const body = xml.replace(/<!--[\s\S]*?-->/g, '');
return [...body.matchAll(/<key>\s*([^<]+?)\s*<\/key>\s*(<false\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'; 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 function compareCapabilities(i: { appId: string[] | null; config: string[]; project: ProjectEntitlements[] | null }): CapMismatch[] {
const out: CapMismatch[] = [];
const has = new Set(i.appId ?? []);
const projCaps = new Map<string, { file: string; key: string }>();
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<string>(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];
out.push({ cap: c, missingIn: 'project', msg: `${c} is in apple.signing.capabilities but no .entitlements file of the iOS project has ${key}: builds ship without it`,
fix: `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)` });
}
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<string[]>, want: string[], o: { tries?: number; waitMs?: number; timeoutMs?: number; sleep?: (ms: number) => Promise<void>; 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<void>(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 <bundle id> ..."), not in `inUse`: unused leftovers whose
* deletion is the owner's call. */
export const unusedStoreboundProfiles = (list: ApiProfile[], inUse: Set<string>): 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' };
}

View file

@ -59,6 +59,8 @@ export interface AppleSnapshot {
iaps: { productId: string; state: string; reviewScreenshot: boolean }[];
expectedProducts: string[];
privacyUrl: UrlCheck;
/** App ID capabilities vs apple.signing.capabilities vs the iOS project's entitlements (cli/lib/signing.ts compareCapabilities). */
capabilityMismatches?: { msg: string; fix: string }[];
/** Local renders (Apple sizes only), checked before upload. Optional: validate fills it when a renders dir exists. */
renders?: RendersCheck;
/** Auto-renewable subscriptions on App Store Connect (validate reads them when the config has subscriptions). */
@ -218,6 +220,8 @@ export function appleRules(s: AppleSnapshot): Finding[] {
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
f.push(...appleSubscriptionRules(s));
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);
if (s.capabilityMismatches && !s.capabilityMismatches.length) add('ok', 'capabilities', 'capabilities match (App ID, config, project entitlements)');
add(urlOk(s.privacyUrl) ? 'ok' : 'blocker', 'privacy-url', `privacy policy ${s.privacyUrl.url}: ${urlMsg(s.privacyUrl)}`, 'privacy-policy skill');
if (s.profile) f.push(...profileRules('apple', s.profile, { reviewDetail: s.reviewDetail, descriptions: s.locales.map(l => ({ locale: l.locale, text: l.description ?? '' })) }));
f.push(...rendersFindings('apple', s.renders));

View file

@ -15,6 +15,7 @@
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { templateBlockers } from './lib/template-answers';
import { ascClient, type J } from './apple/asc';
import { capabilityReport } from './apple/signing';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
import { reviewDemoAccount } from './lib/credentials';
@ -112,6 +113,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
availability: !!avail?.data,
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),
};
}

View file

@ -20,15 +20,24 @@ when the owner said so for this run). The MCP server offers only `apple_signing_
capabilities it has vs `apple.signing.capabilities`, the distribution certificates of the team and whether this
Mac holds a private key for one (a key file in `.storebound/signing/` or an identity in the keychain), App Store
profiles per bundle id (app + `apple.signing.extraBundleIds` for extensions) and whether Xcode has them installed.
It also compares three places a capability must be and names what is missing where: the App ID, the config's
`apple.signing.capabilities` and the iOS project's `.entitlements` (`aps-environment` = PUSH_NOTIFICATIONS,
`com.apple.developer.game-center` = GAME_CENTER, associated domains, Sign in with Apple, iCloud, app groups, ...;
an entitlements file must also be set in CODE_SIGN_ENTITLEMENTS). `apple status` and `validate` warn on the same.
2. **Pick the style.** `apple.signing.style`:
- `automatic` (default): `xcodebuild -allowProvisioningUpdates` with the API key creates and fetches what it needs.
Nothing to do in step 3 unless capabilities are missing. Easiest for one developer on one Mac.
`ensure` only adds missing capabilities to the App ID: it never creates certificates or profiles (Xcode owns
them; its managed profile is renewed with the new capabilities at the next archive). Easiest for one developer.
- `manual`: certificate and profiles from step 3, named in ExportOptions.plist. Best for CI and teams; set the app
target to manual signing with the installed profile in Xcode (Signing & Capabilities) once.
3. **Ensure.** `storebound apple signing ensure --dry`, show it, then without `--dry` after a yes:
- adds missing capabilities (never removes one; extra ones are only reported). Adding a capability makes existing
profiles invalid: `ensure` creates new ones in the same run.
- creates an Apple Distribution certificate only when no valid one has its key on this machine. The RSA key is made
- adds missing capabilities (never removes one; extra ones are only reported), then reads the App ID back (a few
tries, about 45 s at most) and fails with the API's answers when one did not stick. Never report a capability
as added from the POST alone. With manual signing, adding one makes existing profiles stale: `ensure` creates
new ones in the same run.
- lists provisioning profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>"), with
the exact delete command; deleting them is the owner's call, storebound never runs it.
- manual signing only from here on. Creates an Apple Distribution certificate only when no valid one has its key on this machine. The RSA key is made
locally, kept 0600 in `.storebound/signing/<id>.key` next to `<id>.cer`; it is never printed and never leaves the
machine. `--keychain` imports both into the login keychain (or `--keychain <path>`) so xcodebuild can sign.
Back the key up (encrypted). Apple allows 3 distribution certificates per team: at the limit, revoking one is

View file

@ -740,3 +740,30 @@ that fails without the fix (see CONTRIBUTING.md).
"price changes on existing products (held)" section. `apple iap` never changes an existing schedule.
- **Guard:** `allowPriceChanges` / `heldSection` (cli/lib/price-gate.ts), `tests/price-gate.test.ts` (TTY and non-TTY:
a "y" creates the new product and holds the live price; the flag's question names each change).
## 64. `signing ensure` made a manual App Store profile for an automatic-signing app
- **Do:** with `apple.signing.style: automatic` (the default), `apple signing ensure` only keeps the App ID and its
capabilities in step. It never creates certificates or profiles: Xcode owns them (`xcodebuild
-allowProvisioningUpdates` with the API key) and renews its managed profile when the capabilities change. The dry
run says so. Profiles storebound made that nothing uses ("storebound <bundle id> App Store <date>") are listed by
name with the exact `storebound api apple profiles_deleteInstance --param id=<id> --yes` command; deleting them is
the owner's call, never run by storebound.
- **Guard:** `ensureScope`, `unusedStoreboundProfiles` (cli/lib/signing.ts), `tests/signing-capabilities.test.ts`.
## 65. A capability add "succeeded" but the App ID kept only IN_APP_PURCHASE
- **Do:** never report a capability as added from the POST answer. `signing ensure` reads the App ID back after any
capability write (a few tries with growing waits, about 45 s at most) and fails with the API's answers when one is
missing. The capability names were right (CapabilityType: PUSH_NOTIFICATIONS, GAME_CENTER); what an App ID shows
and what a build may use are different things: a provisioning profile keeps the entitlements of the moment it was
made, and an app whose project has no `.entitlements` (or one not set in CODE_SIGN_ENTITLEMENTS) ships without
the capability whatever the App ID says. `apple status`, `apple signing status` and `validate` compare the App ID,
`apple.signing.capabilities` and the project's entitlements and name what is missing where.
- **Guard:** `verifyCapabilities`, `compareCapabilities`, `ENTITLEMENT_CAPABILITIES` (cli/lib/signing.ts),
`checkCapabilitiesStuck` (cli/apple/signing.ts), `tests/signing-capabilities.test.ts` (live-shape
bundleIdCapabilities answers, lag, a write that never sticks).
## 66. `storebound api apple ... --body f.json --yes` only dry-ran from an agent
- **Do:** expected: writes through `storebound api` are sent only from an interactive terminal, by design. Use the
dedicated command the message names (bundle id capabilities: `apple signing ensure`; IAPs: `apple iap`; listing:
`apple listing`, ...), which runs anywhere with its own dry run and read-back.
- **Guard:** `dedicatedCommand` (cli/api/gate.ts), `tests/signing-capabilities.test.ts`.

View file

@ -0,0 +1,143 @@
// Bundle id capabilities from real use (STAR DASH 1.0.1, October 2026): automatic signing must not create profiles or
// certificates, a capability write is read back (lag, never sticking), the App ID / config / project entitlements
// comparison, unused storebound profiles, and the `api` write message naming the dedicated command. Answers follow
// the live bundleIds?include=bundleIdCapabilities shape (capability id = <bundleIdId>_<TYPE>, settings null); every
// id is a placeholder.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { run as signing } from '../cli/apple/signing';
import { compareCapabilities, entitlementKeys, ensureScope, projectEntitlements, unusedStoreboundProfiles, verifyCapabilities } from '../cli/lib/signing';
import { dedicatedCommand, decide } from '../cli/api/gate';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, mockFetch, project, restore } from './helpers/store-mock';
const BID = 'BUNDLE0001';
const cap = (t: string) => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}`, attributes: { settings: null, capabilityType: t } });
const bundles = (types: string[]) => ({
data: [{ type: 'bundleIds', id: BID, attributes: { identifier: 'com.example.notes', platform: 'UNIVERSAL', seedId: 'ABCDE12345' },
relationships: { bundleIdCapabilities: { data: types.map(t => ({ type: 'bundleIdCapabilities', id: `${BID}_${t}` })) } } }],
included: types.map(cap), links: { self: '' }, meta: { paging: { total: 1, limit: 200 } },
});
const page = (data: unknown[]) => ({ data, links: { self: '' }, meta: { paging: { total: data.length, limit: 200 } } });
const sbProfile = { type: 'profiles', id: 'PROF0001', attributes: { name: 'storebound com.example.notes App Store 2026-10-14 10:45', profileType: 'IOS_APP_STORE', profileState: 'ACTIVE', expirationDate: '2027-09-15T01:29:54.000+00:00', uuid: 'profile-uuid-1' } };
const ENT = `<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<!-- <key>com.apple.developer.healthkit</key> commented out -->
<key>aps-environment</key><string>development</string>
<key>com.apple.developer.game-center</key><true/>
<key>com.apple.developer.applesignin</key><false/>
</dict></plist>`;
const PBX = (ref: boolean) => `// !$*UTF8*$!\n{ buildSettings = { CODE_SIGN_STYLE = Automatic; ${ref ? 'CODE_SIGN_ENTITLEMENTS = App/App.entitlements;' : ''} }; }\n`;
beforeEach(() => { upsertTiming.waitMs = 0; });
afterEach(() => { restore(); upsertTiming.waitMs = 3000; });
describe('entitlements and the three-way comparison', () => {
test('entitlementKeys skips comments and <false/> keys', () => {
expect(entitlementKeys(ENT)).toEqual(['aps-environment', 'com.apple.developer.game-center']);
expect(projectEntitlements('App/App.entitlements', ENT, true).caps.map(c => c.cap)).toEqual(['PUSH_NOTIFICATIONS', 'GAME_CENTER']);
});
test('names what is missing where', () => {
const proj = [projectEntitlements('ios/App/App/App.entitlements', ENT, true)];
const m = compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS'], project: proj });
const s = m.map(x => `${x.cap}:${x.missingIn}`);
expect(s).toContain('PUSH_NOTIFICATIONS:app-id'); // config wants it, App ID lacks it
expect(s).toContain('GAME_CENTER:app-id'); // entitlement without App ID capability
expect(s).toContain('GAME_CENTER:config'); // entitlement not in the config
expect(s).not.toContain('IN_APP_PURCHASE:project'); // IAP needs no entitlement
expect(m.find(x => x.cap === 'GAME_CENTER' && x.missingIn === 'app-id')!.msg).toContain('com.apple.developer.game-center');
});
test('config capabilities without entitlements, and an entitlements file the project does not reference', () => {
const none = compareCapabilities({ appId: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [] });
expect(none.map(x => `${x.cap}:${x.missingIn}`)).toEqual(['PUSH_NOTIFICATIONS:project', 'GAME_CENTER:project']);
expect(none[0]!.msg).toContain('aps-environment');
const unref = compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, false)] });
expect(unref.map(x => x.msg).join('\n')).toContain('not referenced by CODE_SIGN_ENTITLEMENTS');
expect(compareCapabilities({ appId: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], config: ['PUSH_NOTIFICATIONS', 'GAME_CENTER'], project: [projectEntitlements('App/App.entitlements', ENT, true)] })).toEqual([]);
expect(compareCapabilities({ appId: ['IN_APP_PURCHASE'], config: ['GAME_CENTER'], project: null }).map(x => x.missingIn)).toEqual(['app-id']);
});
});
describe('read-back after a capability write', () => {
test('lag: the third read has it', async () => {
const reads = [['IN_APP_PURCHASE'], ['IN_APP_PURCHASE'], ['IN_APP_PURCHASE', 'GAME_CENTER']];
const r = await verifyCapabilities(async () => reads.shift() ?? [], ['IN_APP_PURCHASE', 'GAME_CENTER'], { waitMs: 0, sleep: async () => {} });
expect(r).toEqual({ have: ['IN_APP_PURCHASE', 'GAME_CENTER'], missing: [], reads: 3 });
});
test('stops at the timeout', async () => {
const r = await verifyCapabilities(async () => ['IN_APP_PURCHASE'], ['GAME_CENTER'], { tries: 50, waitMs: 1000, timeoutMs: 1500, sleep: async () => {} });
expect(r.missing).toEqual(['GAME_CENTER']);
expect(r.reads).toBeLessThan(50);
});
});
describe('apple signing ensure', () => {
const cfg = (style?: 'automatic' | 'manual') => ({ ...BASE, apple: { ...BASE.apple!, signing: { ...(style ? { style } : {}), capabilities: ['IN_APP_PURCHASE' as const, 'PUSH_NOTIFICATIONS' as const, 'GAME_CENTER' as const] } } });
const routes = (after: string[]) => [
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(['IN_APP_PURCHASE']), times: 1 },
{ match: /^\/v1\/bundleIds\?filter\[identifier\]=com\.example\.notes/, answer: bundles(after) },
{ match: /^\/v1\/certificates\?/, answer: page([]) },
{ match: /^\/v1\/bundleIds\/BUNDLE0001\/profiles/, answer: page([sbProfile]) },
{ match: /^\/v1\/profiles\/PROF0001\/relationships\/certificates/, answer: page([{ type: 'certificates', id: 'CERT0001' }]) },
{ method: 'POST', match: /^\/v1\/bundleIdCapabilities$/, answer: (c: { body: unknown }) => ({ data: cap((c.body as { data: { attributes: { capabilityType: string } } }).data.attributes.capabilityType) }) },
];
test('automatic: --dry plans only the capabilities and says why; lists the unused storebound profile with its delete command', async () => {
ascEnv();
const p = project(cfg(), { 'ios/App/App.xcodeproj/project.pbxproj': PBX(true), 'ios/App/App/App.entitlements': ENT });
const calls = mockFetch(routes(['IN_APP_PURCHASE']));
const r = await capture(() => signing(p.ctx(['ensure', '--dry'])));
const all = r.out + r.err;
expect(all).toContain('automatic signing: ensure only keeps the App ID');
expect(all).toContain('would POST /v1/bundleIdCapabilities');
expect(all).not.toMatch(/POST \/v1\/(profiles|certificates)/);
expect(all).toContain('storebound api apple profiles_deleteInstance --param id=PROF0001');
expect(all).toContain('PUSH_NOTIFICATIONS is in apple.signing.capabilities but not on the App ID');
expect(calls.filter(c => c.method !== 'GET')).toEqual([]);
});
test('automatic: a real run adds capabilities, reads them back and creates no certificate or profile', async () => {
ascEnv();
const p = project(cfg('automatic'));
const calls = mockFetch(routes(['IN_APP_PURCHASE', 'PUSH_NOTIFICATIONS', 'GAME_CENTER']));
const r = await capture(() => signing(p.ctx(['ensure', '--yes'])));
expect(r.exit).toBeNull();
const writes = calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path}`);
expect(writes).toEqual(['POST /v1/bundleIdCapabilities', 'POST /v1/bundleIdCapabilities']);
expect(r.out + r.err).toContain('read back: the App ID has IN_APP_PURCHASE, PUSH_NOTIFICATIONS, GAME_CENTER');
});
test('a write that never sticks fails with the API answers', async () => {
ascEnv();
const p = project(cfg());
mockFetch(routes(['IN_APP_PURCHASE']));
const r = await capture(() => signing(p.ctx(['ensure', '--yes']))).catch(e => ({ out: '', err: String(e), exit: 1 }));
expect(r.err).toContain('capability write did not stick');
expect(r.err).toContain('PUSH_NOTIFICATIONS, GAME_CENTER');
expect(r.err).toContain(`${BID}_GAME_CENTER`); // the POST answers are in the message
});
});
describe('scope and leftovers', () => {
test('ensureScope', () => {
expect(ensureScope(undefined)).toMatchObject({ certificates: false, profiles: false });
expect(ensureScope('manual')).toMatchObject({ certificates: true, profiles: true });
});
test('unusedStoreboundProfiles keeps Xcode-managed and in-use ones', () => {
const managed = { id: 'X', attributes: { name: 'iOS Team Store Provisioning Profile: com.example.notes' }, managed: true };
expect(unusedStoreboundProfiles([sbProfile, managed], new Set()).map(p => p.id)).toEqual(['PROF0001']);
expect(unusedStoreboundProfiles([sbProfile], new Set(['PROF0001']))).toEqual([]);
});
});
describe('storebound api writes outside a terminal', () => {
test('the dry reason says TTY by design and the dedicated command is named', () => {
const d = decide({ kind: 'write', method: 'POST', dry: false, yes: true, getOnly: false, tty: false, agent: false });
expect(d).toMatchObject({ action: 'dry' });
expect((d as { why: string }).why).toContain('by design');
expect(dedicatedCommand('apple', 'bundleIdCapabilities_createInstance')).toContain('apple signing ensure');
expect(dedicatedCommand('apple', 'apps_getInstance')).toBeNull();
});
});