storebound/cli/apple/events.ts

145 lines
11 KiB
TypeScript

// App Store in-app events from apple.events.
// storebound apple events list [--json]
// storebound apple events create [--name "<reference name>"] [--dry] [--json] missing events + localizations
// storebound apple events upload --name "..." [--dry] event card (16:9) + details page (9:16) images per locale
// storebound apple events submit --name "..." [--dry] [--yes] into a review submission (the owner's step; asks)
// An approved event is published by its own schedule (publishStart): sending it to review is the moment it is decided,
// so `submit` asks first and is not an MCP tool. Up to 10 published and 15 approved events per app.
// Pure parts and sources: cli/lib/cpp-events.ts.
import { existsSync, readFileSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { type AppEventDef, checkEventMedia, EVENT_EDITABLE, EVENT_LIMITS, eventAttrDiff, eventCreateBody, eventPatchReq, planEventLocales, reviewItemReq } from '../lib/cpp-events';
import { done, log, printJson, warn } from '../lib/log';
import { textTable } from '../lib/table';
import { imageInfo } from '../shots/spec';
import { ascClient, type J } from './asc';
import { describeReq, execute, one, type Req } from './jsonapi';
import { addItem, draftSubmission } from './submission';
import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'events';
const USAGE = 'usage: storebound apple events list | create [--name n] | upload --name n | submit --name n';
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const sub = a._[0];
if (!sub) throw new Error(USAGE);
const dry = a.has('dry');
const defs = config.apple?.events ?? [];
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const events = await c.all(`/v1/apps/${appId}/appEvents?limit=200`);
const localeOf = (langId: string): string | undefined => config.languages.find(l => l.id === langId)?.apple;
const pick = (): { def: AppEventDef; ev: J | undefined } => {
const name = need(a.get('name'), '--name "<reference name>"');
const def = defs.find(d => d.referenceName === name);
if (!def) throw new Error(`no event "${name}" in apple.events`);
return { def, ev: events.find((e: J) => e.attributes.referenceName === name) };
};
if (sub === 'list') {
const rows = events.map((e: J) => { const s = e.attributes.territorySchedules?.[0] ?? {}; return [e.attributes.referenceName, e.attributes.badge ?? '', e.attributes.eventState ?? '', (s.eventStart ?? '').slice(0, 16), (s.eventEnd ?? '').slice(0, 16), defs.some(d => d.referenceName === e.attributes.referenceName) ? 'config' : '']; });
for (const d of defs.filter(d => !events.some((e: J) => e.attributes.referenceName === d.referenceName))) rows.push([d.referenceName, d.badge, 'not created', d.schedule.eventStart.slice(0, 16), d.schedule.eventEnd.slice(0, 16), 'config']);
if (a.has('json')) { printJson({ events: events.map((e: J) => ({ id: e.id, ...e.attributes })), missing: defs.filter(d => !events.some((e: J) => e.attributes.referenceName === d.referenceName)).map(d => d.referenceName) }); return; }
console.log(rows.length ? textTable(['event', 'badge', 'state', 'start', 'end', 'source'], rows) : 'no in-app events');
const published = events.filter((e: J) => e.attributes.eventState === 'PUBLISHED').length, approved = events.filter((e: J) => ['APPROVED', 'ACCEPTED', 'PUBLISHED'].includes(e.attributes.eventState)).length;
done(TAG, `${published} of ${EVENT_LIMITS.published} published, ${approved} of ${EVENT_LIMITS.approved} approved`);
return;
}
if (sub === 'create') {
const only = a.get('name');
const territories = (await c.all('/v1/territories?limit=200')).map((t: J) => t.id as string);
const reqs: Req[] = [];
const created: { def: AppEventDef; index: number }[] = [];
for (const d of defs.filter(x => !only || x.referenceName === only)) {
const want = Object.entries(d.listings).flatMap(([lang, x]) => { const locale = localeOf(lang); return locale ? [{ locale, ...x }] : []; });
const primary = localeOf(d.primaryLanguage ?? '') ?? want[0]?.locale ?? 'en-US';
const ev = events.find((e: J) => e.attributes.referenceName === d.referenceName);
if (!ev) { created.push({ def: d, index: reqs.length }); reqs.push({ method: 'POST', path: '/v1/appEvents', note: `event "${d.referenceName}" (${d.badge}, ${d.schedule.eventStart} to ${d.schedule.eventEnd})`, body: eventCreateBody(appId, d, primary, d.schedule.territories ?? territories) }); continue; }
const drift = eventAttrDiff(d, ev.attributes);
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) {
if (drift.lines.length) warn(TAG, `"${d.referenceName}" is ${ev.attributes.eventState} and differs from the config: ${drift.lines.join('; ')} (not editable: change it in App Store Connect)`);
else log(TAG, `"${d.referenceName}": ${ev.attributes.eventState}, not editable`);
continue;
}
const patch = eventPatchReq(ev.id, d.referenceName, drift.attrs, drift.lines);
if (patch) reqs.push(patch);
const have = (await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
reqs.push(...planEventLocales(ev.id, have, want));
}
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });
if (!reqs.length) { done(TAG, 'in-app events up to date'); return; }
if (dry) {
await execute(c.asc, reqs, { dry, tag: TAG });
for (const x of created) log(TAG, `then: ${Object.keys(x.def.listings).length} localization(s) for "${x.def.referenceName}"`);
done(TAG, 'dry run: nothing changed');
return;
}
// reference names are not known to be unique on Apple's side: each new event is looked up right before it is
// created (fresh list, then the journal of recent creates for the list lag) and never sent twice
for (const [i, r] of reqs.entries()) {
const x = created.find(e => e.index === i);
if (!x) { await execute(c.asc, [r], { dry: false, tag: TAG }); continue; }
const name = x.def.referenceName;
const made = await findOrCreate<J>({ what: `in-app event "${name}"`, tag: TAG,
find: async () => (await c.all(`/v1/apps/${appId}/appEvents?limit=200`)).find((e: J) => e.attributes.referenceName === name) ?? null,
journal: { root, key: `apple:${appId}:event:${name}`, byId: id => getById(c.asc, `/v1/appEvents/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', r.path, r.body)).data });
log(TAG, `${describeReq(r)}: ${made.created ? 'done' : 'exists already'}`);
const id = made.value.id as string;
const want = Object.entries(x.def.listings).flatMap(([lang, t]) => { const locale = localeOf(lang); return locale ? [{ locale, ...t }] : []; });
const have = made.created ? [] : (await c.all(`/v1/appEvents/${id}/localizations?limit=200`)).map((l: J) => ({ id: l.id, ...l.attributes }));
await execute(c.asc, planEventLocales(id, have, want), { dry: false, tag: TAG });
}
done(TAG, 'in-app events created / updated; next: storebound apple events upload --name "<event>"');
return;
}
if (sub === 'upload') {
const { def, ev } = pick();
if (!ev) throw new Error(`"${def.referenceName}" is not on App Store Connect yet (storebound apple events create)`);
if (!EVENT_EDITABLE.has(ev.attributes.eventState)) throw new Error(`"${def.referenceName}": ${ev.attributes.eventState}, media can only change on a draft`);
let n = 0;
for (const loc of await c.all(`/v1/appEvents/${ev.id}/localizations?limit=200`)) {
const lang = config.languages.find(l => l.apple === loc.attributes.locale);
if (!lang) continue;
const have = await c.all(`/v1/appEventLocalizations/${loc.id}/appEventScreenshots?limit=50`);
for (const [key, kind] of [['card', 'EVENT_CARD'], ['details', 'EVENT_DETAILS_PAGE']] as const) {
const tpl = def.media?.[key];
if (!tpl) continue;
const file = resolve(tpl.replaceAll('{lang}', lang.id));
if (!existsSync(file)) { warn(TAG, `${loc.attributes.locale} ${kind}: no ${file}`); continue; }
const info = imageInfo(readFileSync(file));
const problems = checkEventMedia(kind, info.w, info.h, info.format);
if (problems.length) throw new Error(`${file}: ${problems.join('; ')}`);
if (have.some((s: J) => s.attributes.appEventAssetType === kind && s.attributes.fileSize === info.bytes && ['COMPLETE', 'UPLOAD_COMPLETE'].includes(s.attributes.assetDeliveryState?.state ?? 'COMPLETE'))) { log(TAG, `${loc.attributes.locale} ${kind}: up to date`); continue; }
if (dry) { log(TAG, `${loc.attributes.locale} ${kind}: would upload ${file} (${info.w}x${info.h})`); n++; continue; }
for (const s of have.filter((x: J) => x.attributes.appEventAssetType === kind)) await c.asc('DELETE', `/v1/appEventScreenshots/${s.id}`);
await c.uploadAsset('appEventScreenshots', { appEventLocalization: { data: { type: 'appEventLocalizations', id: loc.id } } }, file, { appEventAssetType: kind });
log(TAG, `${loc.attributes.locale} ${kind}: uploaded`);
n++;
}
}
done(TAG, `${n} image(s) ${dry ? 'to upload (dry run)' : 'uploaded'} for "${def.referenceName}"`);
return;
}
if (sub === 'submit') {
const { def, ev } = pick();
if (!ev) throw new Error(`"${def.referenceName}" is not on App Store Connect yet`);
if (!EVENT_EDITABLE.has(ev.attributes.eventState) && ev.attributes.eventState !== 'READY_FOR_REVIEW') { done(TAG, `"${def.referenceName}" is ${ev.attributes.eventState}, nothing to submit`); return; }
const draft = (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id as string | undefined;
const create: Req = { method: 'POST', path: '/v1/reviewSubmissions', note: 'new draft review submission', body: { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } } };
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), reviewItemReq(draft ?? '${submission}', 'appEvent', ev.id, `in-app event "${def.referenceName}"`), { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
if (!(await confirm(ctx, `Send in-app event "${def.referenceName}" to App Review? Once approved it is published on ${def.schedule.publishStart} by its schedule.`))) return;
const sid = draft ?? await draftSubmission(c, appId, root, TAG);
await addItem(c, sid, 'appEvent', ev.id, `in-app event "${def.referenceName}"`, TAG);
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
done(TAG, `"${def.referenceName}" submitted (review submission ${sid})`);
return;
}
throw new Error(`unknown events command "${sub}"\n${USAGE}`);
}