storebound/cli/lib/cpp-events.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

202 lines
15 KiB
TypeScript

// App Store custom product pages and in-app events, as pure parts: config checks, the create requests and what to
// change. The commands (cli/apple/cpp.ts, cli/apple/events.ts) read, plan, show the dry run and send. Creating and
// uploading change nothing customers see; sending a page or an event to App Review, and making a page visible, are
// the owner's (terminal, asks first, not MCP tools).
// Sources (App Store Connect API 4.5.1 OpenAPI spec + docs, App Store Connect Help; checked October 2026):
// Custom product pages: POST /v1/appCustomProductPages (name, app, optional appStoreVersionTemplate /
// customProductPageTemplate, inline appCustomProductPageVersions + appCustomProductPageLocalizations with locale and
// promotionalText; Apple's example request), PATCH /v1/appCustomProductPages/{id} (name, visible),
// POST /v1/appCustomProductPageVersions (deepLink), POST /v1/appCustomProductPageLocalizations (locale,
// promotionalText), screenshots: appScreenshotSets with the appCustomProductPageLocalization relationship.
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-appcustomproductpages
// Help: up to 70 custom product pages per app; each needs App Review (independent of an app update); the deep link
// works from iOS / iPadOS 18 once approved. https://developer.apple.com/app-store/custom-product-pages/
// In-app events: POST /v1/appEvents (referenceName, badge, deepLink, purchaseRequirement, primaryLocale, priority,
// purpose, territorySchedules [{ territories, publishStart, eventStart, eventEnd }]), POST /v1/appEventLocalizations
// (locale, name, shortDescription, longDescription), POST /v1/appEventScreenshots (fileName, fileSize,
// appEventAssetType EVENT_CARD | EVENT_DETAILS_PAGE) + the upload operations.
// Help "Offer In-App Events": reference name up to 64 characters, event name 30, short description 50, long
// description 120; an event lasts 15 minutes to 31 days; it can be published at most 14 days before it starts;
// up to 10 published and 15 approved events per app; events need App Review.
// https://developer.apple.com/help/app-store-connect/offer-in-app-events/offer-in-app-events
// Media: event card 16:9, 1920x1080 to 3840x2160; details page 9:16, 1080x1920 to 2160x3840; .jpg / .png.
// https://developer.apple.com/help/app-store-connect/reference/in-app-events/in-app-event-media-and-audio-specifications
// Sending either to review: a review submission item with appCustomProductPageVersion | appEvent
// (https://developer.apple.com/documentation/appstoreconnectapi/review-submissions).
// Deprecation note (API 4.5.1): screenshot sets and app event screenshots are deprecated in favour of the App Asset
// Library; they still work and storebound uses them until the asset library is the only way.
import type { Req } from '../apple/jsonapi';
import { one } from '../apple/jsonapi';
import type { Language } from './config';
export const CPP_LIMITS = { pages: 70, promotionalText: 170 } as const;
export const EVENT_LIMITS = { referenceName: 64, name: 30, shortDescription: 50, longDescription: 120, published: 10, approved: 15, maxDays: 31, minMinutes: 15, publishLeadDays: 14 } as const;
export const EVENT_BADGES = ['LIVE_EVENT', 'PREMIERE', 'CHALLENGE', 'COMPETITION', 'NEW_SEASON', 'MAJOR_UPDATE', 'SPECIAL_EVENT'] as const;
export const EVENT_PURPOSES = ['APPROPRIATE_FOR_ALL_USERS', 'ATTRACT_NEW_USERS', 'KEEP_ACTIVE_USERS_INFORMED', 'BRING_BACK_LAPSED_USERS'] as const;
/** Event media: [min w, min h, max w, max h]. */
export const EVENT_MEDIA = { EVENT_CARD: [1920, 1080, 3840, 2160], EVENT_DETAILS_PAGE: [1080, 1920, 2160, 3840] } as const;
export interface CustomProductPageDef {
/** Page name (unique per app; shown only in App Store Connect). */
name: string;
/** Screenshots from this shots variant (`storebound shots render --variant <id>`); default: the base renders. */
variant?: string;
/** Universal link or custom URL that "Open" goes to (iOS / iPadOS 18+, after approval). */
deepLink?: string;
/** Language id -> promotional text (170). Every language with an App Store locale gets a localization. */
promotionalText?: Record<string, string>;
}
export interface AppEventDef {
/** Reference name (unique per app, up to 64 characters). */
referenceName: string;
badge: typeof EVENT_BADGES[number];
purpose?: typeof EVENT_PURPOSES[number];
priority?: 'high' | 'normal';
deepLink?: string;
/** Text shown when the event needs a purchase or subscription, e.g. "Pro subscription". */
purchaseRequirement?: string;
/** Language id of the primary locale (default: the first language with an App Store locale). */
primaryLanguage?: string;
/** When and where: ISO date-times; territories (alpha-3) default to every territory of the app. */
schedule: { publishStart: string; eventStart: string; eventEnd: string; territories?: string[] };
/** Language id -> name (30), shortDescription (50), longDescription (120). */
listings: Record<string, { name: string; shortDescription: string; longDescription: string }>;
/** Images (paths from the project root; "{lang}" is replaced by the language id): card 16:9, details 9:16. */
media?: { card?: string; details?: string };
}
const len = (s: string): number => [...s].length;
export function validateCpp(pages: CustomProductPageDef[] | undefined, langIds: Set<string>, variants: string[]): string[] {
const e: string[] = [];
const names = new Set<string>();
if ((pages ?? []).length > CPP_LIMITS.pages) e.push(`apple.customProductPages: at most ${CPP_LIMITS.pages} per app`);
for (const [i, p] of (pages ?? []).entries()) {
const at = `apple.customProductPages[${i}]`;
if (!p.name) e.push(`${at}.name: missing`);
if (names.has(p.name)) e.push(`${at}.name: duplicate "${p.name}"`);
names.add(p.name);
if (p.variant && !variants.includes(p.variant)) e.push(`${at}.variant: "${p.variant}" is not in shots.variants`);
if (p.deepLink && !/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(p.deepLink)) e.push(`${at}.deepLink: a universal link (https://...) or custom URL (myapp://...)`);
for (const [l, t] of Object.entries(p.promotionalText ?? {})) {
if (!langIds.has(l)) e.push(`${at}.promotionalText.${l}: not in languages`);
if (len(t) > CPP_LIMITS.promotionalText) e.push(`${at}.promotionalText.${l}: ${len(t)} > ${CPP_LIMITS.promotionalText}`);
}
}
return e;
}
export function validateEvents(events: AppEventDef[] | undefined, langIds: Set<string>): string[] {
const e: string[] = [];
const names = new Set<string>();
for (const [i, ev] of (events ?? []).entries()) {
const at = `apple.events[${i}]`;
if (!ev.referenceName || len(ev.referenceName) > EVENT_LIMITS.referenceName) e.push(`${at}.referenceName: 1-${EVENT_LIMITS.referenceName} characters`);
if (names.has(ev.referenceName)) e.push(`${at}.referenceName: duplicate`);
names.add(ev.referenceName);
if (!EVENT_BADGES.includes(ev.badge)) e.push(`${at}.badge: ${EVENT_BADGES.join(' | ')}`);
if (ev.purpose && !EVENT_PURPOSES.includes(ev.purpose)) e.push(`${at}.purpose: ${EVENT_PURPOSES.join(' | ')}`);
if (ev.priority && ev.priority !== 'high' && ev.priority !== 'normal') e.push(`${at}.priority: high | normal`);
if (ev.primaryLanguage && !langIds.has(ev.primaryLanguage)) e.push(`${at}.primaryLanguage: not in languages`);
const s = ev.schedule;
const t = (x: string | undefined) => (x ? Date.parse(x) : NaN);
if (!s || [s.publishStart, s.eventStart, s.eventEnd].some(x => Number.isNaN(t(x)))) e.push(`${at}.schedule: publishStart, eventStart, eventEnd as ISO date-times`);
else {
const dur = t(s.eventEnd) - t(s.eventStart);
if (dur < EVENT_LIMITS.minMinutes * 60_000) e.push(`${at}.schedule: an event lasts at least ${EVENT_LIMITS.minMinutes} minutes`);
if (dur > EVENT_LIMITS.maxDays * 86_400_000) e.push(`${at}.schedule: an event lasts at most ${EVENT_LIMITS.maxDays} days`);
if (t(s.publishStart) > t(s.eventStart)) e.push(`${at}.schedule: publishStart after eventStart`);
if (t(s.eventStart) - t(s.publishStart) > EVENT_LIMITS.publishLeadDays * 86_400_000) e.push(`${at}.schedule: published at most ${EVENT_LIMITS.publishLeadDays} days before it starts`);
if (s.territories && !s.territories.every(x => /^[A-Z]{3}$/.test(x))) e.push(`${at}.schedule.territories: alpha-3 (USA)`);
}
if (!ev.listings || !Object.keys(ev.listings).length) e.push(`${at}.listings: at least one language`);
for (const [l, x] of Object.entries(ev.listings ?? {})) {
if (!langIds.has(l)) e.push(`${at}.listings.${l}: not in languages`);
for (const k of ['name', 'shortDescription', 'longDescription'] as const) {
if (!x[k]) e.push(`${at}.listings.${l}.${k}: missing`);
else if (len(x[k]) > EVENT_LIMITS[k]) e.push(`${at}.listings.${l}.${k}: ${len(x[k])} > ${EVENT_LIMITS[k]}`);
}
}
}
return e;
}
/** POST /v1/appCustomProductPages with its first version and one localization per locale (Apple's example shape).
* templateVersionId copies the screenshots / previews of an App Store version (optional). */
export function cppCreateBody(appId: string, p: CustomProductPageDef, locales: { locale: string; promotionalText?: string }[], templateVersionId?: string) {
const locIds = locales.map((_, i) => `\${cpp-loc-${i}}`);
return {
data: { type: 'appCustomProductPages', attributes: { name: p.name }, relationships: {
app: one('apps', appId), ...(templateVersionId ? { appStoreVersionTemplate: one('appStoreVersions', templateVersionId) } : {}),
appCustomProductPageVersions: { data: [{ type: 'appCustomProductPageVersions', id: '${cpp-version}' }] } } },
included: [
{ type: 'appCustomProductPageVersions', id: '${cpp-version}', ...(p.deepLink ? { attributes: { deepLink: p.deepLink } } : {}),
relationships: { appCustomProductPageLocalizations: { data: locIds.map(id => ({ type: 'appCustomProductPageLocalizations', id })) } } },
...locales.map((l, i) => ({ type: 'appCustomProductPageLocalizations', id: locIds[i], attributes: { locale: l.locale, ...(l.promotionalText ? { promotionalText: l.promotionalText } : {}) } })),
],
};
}
/** Localizations of an editable page version to add (POST) or change (PATCH promotional text). Pure. */
export function planCppLocales(versionId: string, have: { id: string; locale: string; promotionalText?: string | null }[], want: { locale: string; promotionalText?: string }[]): Req[] {
const out: Req[] = [];
for (const w of want) {
const cur = have.find(h => h.locale === w.locale);
if (!cur) out.push({ method: 'POST', path: '/v1/appCustomProductPageLocalizations', note: w.locale,
body: { data: { type: 'appCustomProductPageLocalizations', attributes: { locale: w.locale, ...(w.promotionalText ? { promotionalText: w.promotionalText } : {}) }, relationships: { appCustomProductPageVersion: one('appCustomProductPageVersions', versionId) } } } });
else if (w.promotionalText !== undefined && (cur.promotionalText ?? '') !== w.promotionalText) out.push({ method: 'PATCH', path: `/v1/appCustomProductPageLocalizations/${cur.id}`, note: `${w.locale} promotional text`,
body: { data: { type: 'appCustomProductPageLocalizations', id: cur.id, attributes: { promotionalText: w.promotionalText } } } });
}
return out;
}
/** Locales of a page from the config: every language with an App Store locale. */
export function cppLocales(p: CustomProductPageDef, langs: Language[]): { locale: string; langId: string; promotionalText?: string }[] {
return langs.filter(l => l.apple).map(l => ({ locale: l.apple!, langId: l.id, ...(p.promotionalText?.[l.id] ? { promotionalText: p.promotionalText[l.id] } : {}) }));
}
/** Editable states of a custom product page version / in-app event (can still be changed and uploaded to). */
export const CPP_EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'REJECTED']);
export const EVENT_EDITABLE = new Set(['DRAFT', 'REJECTED']);
/** POST /v1/appEvents. */
export function eventCreateBody(appId: string, ev: AppEventDef, primaryLocale: string, territories: string[]) {
return { data: { type: 'appEvents', attributes: {
referenceName: ev.referenceName, badge: ev.badge, primaryLocale, priority: (ev.priority ?? 'normal').toUpperCase(),
purpose: ev.purpose ?? 'APPROPRIATE_FOR_ALL_USERS', ...(ev.deepLink ? { deepLink: ev.deepLink } : {}), ...(ev.purchaseRequirement ? { purchaseRequirement: ev.purchaseRequirement } : {}),
territorySchedules: [{ territories, publishStart: ev.schedule.publishStart, eventStart: ev.schedule.eventStart, eventEnd: ev.schedule.eventEnd }],
}, relationships: { app: one('apps', appId) } } };
}
/** Event localizations to add or change. Pure. */
export function planEventLocales(eventId: string, have: { id: string; locale: string; name?: string | null; shortDescription?: string | null; longDescription?: string | null }[],
want: { locale: string; name: string; shortDescription: string; longDescription: string }[]): Req[] {
const out: Req[] = [];
for (const w of want) {
const cur = have.find(h => h.locale === w.locale);
const attrs = { name: w.name, shortDescription: w.shortDescription, longDescription: w.longDescription };
if (!cur) out.push({ method: 'POST', path: '/v1/appEventLocalizations', note: w.locale, body: { data: { type: 'appEventLocalizations', attributes: { locale: w.locale, ...attrs }, relationships: { appEvent: one('appEvents', eventId) } } } });
else {
const changed = Object.fromEntries(Object.entries(attrs).filter(([k, v]) => (cur as Record<string, unknown>)[k] !== v));
if (Object.keys(changed).length) out.push({ method: 'PATCH', path: `/v1/appEventLocalizations/${cur.id}`, note: `${w.locale} ${Object.keys(changed).join(', ')}`, body: { data: { type: 'appEventLocalizations', id: cur.id, attributes: changed } } });
}
}
return out;
}
/** Event media size check (card 16:9 landscape, details 9:16 portrait). Pure. */
export function checkEventMedia(kind: keyof typeof EVENT_MEDIA, w: number, h: number, format: string): string[] {
const [minW, minH, maxW, maxH] = EVENT_MEDIA[kind];
const e: string[] = [];
if (format !== 'png' && format !== 'jpeg') e.push('PNG or JPEG only');
if (w < minW || h < minH || w > maxW || h > maxH) e.push(`${w}x${h}: ${minW}x${minH} to ${maxW}x${maxH}`);
if (Math.abs(w / h - minW / minH) > 0.01) e.push(`${w}x${h}: aspect ${kind === 'EVENT_CARD' ? '16:9' : '9:16'}`);
return e;
}
/** POST /v1/reviewSubmissionItems for a custom product page version or an in-app event (into a draft submission). */
export const reviewItemReq = (submissionId: string, kind: 'appCustomProductPageVersion' | 'appEvent', id: string, label: string): Req => ({
method: 'POST', path: '/v1/reviewSubmissionItems', note: label,
body: { data: { type: 'reviewSubmissionItems', relationships: { reviewSubmission: one('reviewSubmissions', submissionId), [kind]: one(kind === 'appEvent' ? 'appEvents' : 'appCustomProductPageVersions', id) } } },
});