storebound/cli/lib/build-ops.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks)
- apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app
  version (--with-version), --no-submit keeps the draft; read-only MCP plan
- apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten)
- whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits
- apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API)
- apple cpp and apple events: list, create, upload, submit (asks)
- reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV
- triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS
- aso rank: iTunes Search API ranks and competitors, rate limited
- ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks
- tests against recorded, sanitized answers; skills, README, CHANGELOG
2026-10-11 23:58:17 +08:00

89 lines
5.9 KiB
TypeScript

// TestFlight build housekeeping as pure parts: waiting for an uploaded build to finish processing, and expiring old
// builds. The command (cli/apple/testflight.ts wait | expire) reads, calls these and prints / sends.
// API (App Store Connect API 4.5.1 OpenAPI spec and docs, checked October 2026):
// GET /v1/builds filter[app], filter[version], filter[expired], filter[preReleaseVersion.platform], sort=-uploadedDate,
// include=preReleaseVersion,buildBetaDetail,appStoreVersion
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds
// Build.processingState: PROCESSING | FAILED | INVALID | VALID
// PATCH /v1/builds/{id} { expired: true } "Expire a build or change its encryption exemption setting."
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builds-_id_
// Expiring is permanent: testers lose the build, it cannot be un-expired. So `expire` is a dry run first, then a
// terminal confirmation; it is not an MCP tool.
import type { Req } from '../apple/jsonapi';
export type WaitOutcome = 'missing' | 'processing' | 'valid' | 'failed';
export interface BuildSeen { found: boolean; processingState?: string; internal?: string | null; external?: string | null }
/** One poll result -> what it means for the waiter. A build that is not listed yet is normal for some minutes after
* the upload (App Store Connect lists it once processing starts). */
export function waitOutcome(b: BuildSeen): WaitOutcome {
if (!b.found) return 'missing';
if (b.processingState === 'VALID') return 'valid';
if (b.processingState === 'FAILED' || b.processingState === 'INVALID') return 'failed';
return 'processing';
}
/** Poll delay: 20 s at first, x1.5 per poll, at most 2 minutes (processing usually takes 5 to 30 minutes). */
export function waitDelay(prevMs: number | null): number {
return prevMs === null ? 20_000 : Math.min(Math.round(prevMs * 1.5), 120_000);
}
/** Exit codes of `testflight wait`: 0 VALID, 1 FAILED / INVALID, 7 timeout (the same as `apple status --wait-review`). */
export const BUILD_WAIT_EXIT = { valid: 0, failed: 1, timeout: 7 } as const;
/** What the build state means for the next step, one line. */
export function validHint(b: BuildSeen): string {
if (b.internal === 'MISSING_EXPORT_COMPLIANCE' || b.external === 'MISSING_EXPORT_COMPLIANCE')
return 'export compliance missing: storebound apple encryption --build N (from apple.usesNonExemptEncryption)';
return 'ready: storebound apple testflight distribute --build N --group <name>, or apple listing --build N';
}
export interface ExpireCandidate {
id: string; build: string; version?: string; uploaded: string; processingState?: string;
/** Attached to an App Store version (in review, live or being prepared): never expired by storebound. */
attached: boolean;
}
export interface ExpirePlan { expire: ExpireCandidate[]; keep: ExpireCandidate[]; protected: { b: ExpireCandidate; why: string }[]; reqs: Req[] }
/**
* Which builds to expire. `builds` = the app's builds that are not expired yet (any order). keep = keep the newest N
* builds (by upload date; protected builds count as kept), olderThanDays = only builds uploaded before now - D days.
* With both, a build must be beyond the newest N AND older than D days. Builds attached to an App Store version and
* builds still processing are never expired. Pure.
*/
export function planExpire(builds: ExpireCandidate[], o: { keep?: number; olderThanDays?: number; now?: number }): ExpirePlan {
if (o.keep === undefined && o.olderThanDays === undefined) throw new Error('--keep <n> or --older-than <days> (or both)');
if (o.keep !== undefined && !(Number.isInteger(o.keep) && o.keep >= 1)) throw new Error('--keep: a whole number >= 1 (the newest build always stays)');
if (o.olderThanDays !== undefined && !(o.olderThanDays >= 1)) throw new Error('--older-than: days >= 1');
const now = o.now ?? Date.now();
const sorted = [...builds].sort((a, b) => b.uploaded.localeCompare(a.uploaded));
const plan: ExpirePlan = { expire: [], keep: [], protected: [], reqs: [] };
sorted.forEach((b, i) => {
if (b.attached) { plan.protected.push({ b, why: 'attached to an App Store version' }); return; }
if (b.processingState && b.processingState !== 'VALID' && b.processingState !== 'FAILED' && b.processingState !== 'INVALID') { plan.protected.push({ b, why: `still ${b.processingState}` }); return; }
if (i === 0) { plan.keep.push(b); return; }
const beyondKeep = o.keep === undefined || i >= o.keep;
const old = o.olderThanDays === undefined || now - Date.parse(b.uploaded) > o.olderThanDays * 86_400_000;
(beyondKeep && old ? plan.expire : plan.keep).push(b);
});
plan.reqs = plan.expire.map(b => ({ method: 'PATCH', path: `/v1/builds/${b.id}`, note: `expire build ${b.build}${b.version ? ` (${b.version})` : ''}, uploaded ${b.uploaded.slice(0, 10)}`,
body: { data: { type: 'builds', id: b.id, attributes: { expired: true } } } }));
return plan;
}
/** Builds response (include=preReleaseVersion,appStoreVersion) -> expire candidates. Pure. */
export function expireCandidates(pages: { data?: { id: string; attributes?: Record<string, unknown>; relationships?: Record<string, { data?: { id: string } | null }> }[]; included?: { type: string; id: string; attributes?: { version?: string } }[] }[]): ExpireCandidate[] {
const out: ExpireCandidate[] = [];
for (const page of pages) {
const pre = new Map((page.included ?? []).filter(x => x.type === 'preReleaseVersions').map(x => [x.id, x.attributes?.version] as const));
for (const b of page.data ?? []) {
const a = b.attributes ?? {};
if (a.expired === true) continue;
out.push({ id: b.id, build: String(a.version ?? '?'), version: pre.get(b.relationships?.preReleaseVersion?.data?.id ?? ''), uploaded: String(a.uploadedDate ?? ''),
processingState: a.processingState as string | undefined, attached: !!b.relationships?.appStoreVersion?.data });
}
}
return out;
}