storebound/cli/apple/listing.ts

249 lines
19 KiB
TypeScript

// App Store version + listing text (idempotent; re-run after editing the App Store listing file).
// storebound apple listing --version 1.0.0 [--platform ios|macos|tvos|visionos] [--build 3] [--include-drafts] [--dry] [--json]
// The platform: --platform, else the one where that version is editable (both: asks), else the only one / iOS.
// Every run first reads App Store Connect and prints the plan: the version (keep, update the editable one, create, or
// refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there
// (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or
// status=translate (listing translate) are skipped unless --include-drafts.
// - the editable iOS version: versionString, copyright, release type (apple.releaseType, MANUAL when new and unset); with --build that processed build is attached
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
// support + marketing URL per locale
// And the other way round (read-only on App Store Connect):
// storebound apple listing pull [--version x.y.z] [--write] [--json]
// the live texts (the editable version if there is one, else the newest; --version picks one) into the App Store
// listing file: shows the diff, writes only with --write.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { type AppleCopy, parseAppStoreListing } from '../lib/copy';
import { fieldsMentions, hitLine, type PlatformHit, refuseMentions } from '../lib/cross-store';
import { compactDiff } from '../lib/diff';
import { mergeAppleListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
import { createOnce, findOrCreate, getById } from '../lib/upsert';
import { appleName } from '../lib/config';
import { wantedRelease } from '../lib/release-type';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log';
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
import { stdinIsTTY } from '../lib/tty';
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
export function readAppleCopy(ctx: Ctx) {
const { config, resolve } = ctx.loaded;
const only = ctx.args.list('only');
const all = parseAppStoreListing(readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8'));
return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l)));
}
/** apple listing pull: live texts -> diff -> (with --write) the App Store listing file. */
async function pull(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const a = ctx.args;
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const want = a.get('version');
const flag = parsePlatform(a.get('platform'));
const platform = flag ?? pickPlatform(await allVersions(c, appId), want ? [(x: J) => x.attributes.versionString === want && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), (x: J) => x.attributes.versionString === want] : (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, want ? `version ${want}` : 'an editable version');
const versions = await c.all(versionsPath(appId, platform));
const sorted = [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')));
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion(platform)) ?? sorted[0];
if (!v) throw new Error(want ? `no ${platformLabel(platform)} version ${want} on App Store Connect` : `no ${platformLabel(platform)} version on App Store Connect yet`);
log('apple', `pulling version ${v.attributes.versionString} (${platformLabel(platform)})`);
const infos = await c.all(`/v1/apps/${appId}/appInfos`);
const live = v.attributes.appVersionState ?? v.attributes.appStoreState;
// the app info that belongs to the version: the editable one while a version is being prepared, else the live one
const info = ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(live)
? infos.find((i: J) => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0]
: await c.editableAppInfo();
if (!info) throw new Error(noAppInfo(appId));
const remote: Record<string, AppleCopy> = {};
for (const l of await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`)) remote[l.attributes.locale] = { name: l.attributes.name ?? undefined, subtitle: l.attributes.subtitle ?? undefined };
for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=200`)) {
const at = l.attributes;
remote[at.locale] = { ...remote[at.locale], description: at.description ?? undefined, keywords: at.keywords ?? undefined, promotionalText: at.promotionalText ?? undefined, whatsNew: at.whatsNew ?? undefined };
}
const file = resolve(config.apple?.listing ?? 'store/appstore.md');
const before = existsSync(file) ? readFileSync(file, 'utf8') : '# App Store listing copy\n\nPulled from App Store Connect by `storebound apple listing pull`.\n';
const r = mergeAppleListing(before, remote, appleName(config));
const over = overLimits(r.changes, 'apple');
const diff = compactDiff(before, r.md);
if (a.has('json')) printJson({ version: v.attributes.versionString, state: live, file, write: a.has('write') && !a.has('dry'), changes: r.changes, added: r.added, overLimits: over, diff });
else {
log('apple', `pull: version ${v.attributes.versionString} (${live}), ${Object.keys(remote).length} locales`);
for (const ch of r.changes) log('apple', `${ch.section} ${ch.field}: ${ch.from === null ? 'new' : 'changed'}`);
if (diff.length) console.log(diff.join('\n'));
}
for (const o of over) warn('apple', `over the limit on the store: ${o}`);
if (!r.changes.length) { done('apple', `${file} already matches the store`); return; }
if (!a.has('write') || a.has('dry')) { done('apple', `${r.changes.length} change(s); nothing written (add --write to update ${file})`); return; }
writeFileSync(file, r.md);
done('apple', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new locales ${r.added.join(', ')}` : ''}`);
}
/** The plan of one `apple listing --version` run, from read-only GETs. */
export interface AppleListingPlan {
version: string;
/** the App Store platform of the version (IOS, MAC_OS, ...) */
platform: AscPlatform;
versionPlan: VersionPlan;
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
appInfo: { id: string; state: string; locales: ItemPlan[] };
/** null: the version is created first; App Store Connect copies the previous version's texts, then these are applied. */
versionLocales: ItemPlan[] | null;
wanted: Record<string, Record<string, string | undefined>>;
skipped: Skipped[];
notes: string[];
changes: number;
/** Android, Google Play, ... in the texts this run sends (Guideline 2.3.10): the run is refused (pitfall 77) */
mentions: PlatformHit[];
}
type Asc = Awaited<ReturnType<typeof ascClient>>;
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise<AppleListingPlan> {
const { config, resolve } = ctx.loaded;
const app = config.app;
const listing = readAppleCopy(ctx);
const md = readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8');
const drafts = draftSections(md, 'apple');
const include = ctx.args.has('include-drafts');
const skipped: Skipped[] = [];
const notes: string[] = [];
for (const l of Object.keys(listing)) {
const st = drafts.get(l);
if (!st) continue;
if (include) notes.push(`${l}: ${st === 'draft' ? 'machine-translated draft' : 'untranslated (status=translate)'} included (--include-drafts)`);
else { skipped.push({ key: l, reason: draftReason(st) }); delete listing[l]; }
}
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
const rows = await c.all(versionsPath(appId, platform));
const rel = wantedRelease(config.apple);
const versionPlan = planVersion(rows, version, { copyright, releaseType: rel?.releaseType, earliestReleaseDate: rel?.earliestReleaseDate }, platform);
let build: AppleListingPlan['build'] = null;
const number = ctx.args.get('build');
if (number) {
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=${platform}`))[0];
const cur = versionPlan.id ? (await c.maybe(`/v1/appStoreVersions/${versionPlan.id}/build`))?.data : null;
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
build = !b ? { number, id: null, current, action: 'refuse', reason: `build ${version} (${number}) not found (still uploading? check TestFlight)` }
: b.attributes.processingState !== 'VALID' ? { number, id: b.id, current, action: 'refuse', reason: `build ${number} is ${b.attributes.processingState}; wait until VALID` }
: cur?.id === b.id ? { number, id: b.id, current, action: 'same', reason: `build ${number} is already attached` }
: { number, id: b.id, current, action: 'attach', reason: `attach build ${version} (${number})${current ? ` instead of ${current}` : ''}` };
}
const info = await c.editableAppInfo();
const infoWant = Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { name: f.name ?? appleName(config), subtitle: f.subtitle, privacyPolicyUrl: app.privacyPolicyUrl }]));
const infoLocs = planLocales(await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`), infoWant);
if (versionPlan.first && Object.values(listing).some(f => f.whatsNew)) notes.push('what\'s new is left out: App Store Connect has no what\'s new on the first version');
const wanted = Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { description: f.description, keywords: f.keywords, promotionalText: f.promotionalText,
whatsNew: versionPlan.first ? undefined : f.whatsNew, supportUrl: app.supportUrl, marketingUrl: app.marketingUrl ?? app.website }]));
const versionLocales = versionPlan.id ? planLocales(await c.all(`/v1/appStoreVersions/${versionPlan.id}/appStoreVersionLocalizations?limit=200`), wanted) : null;
if (!versionLocales && versionPlan.action === 'create') notes.push('the new version gets the previous version\'s texts from App Store Connect; the version texts below are applied after it is created');
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
const allow = config.listing?.allowPlatformMentions;
const mentions = Object.entries(listing).flatMap(([l, f]) => fieldsMentions('apple', l, { name: f.name, subtitle: f.subtitle, promotionalText: f.promotionalText,
keywords: f.keywords, description: f.description, whatsNew: versionPlan.first ? undefined : f.whatsNew }, allow));
return { mentions, version, platform, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
versionLocales, wanted, skipped, notes, changes };
}
/** The plan as text lines. Pure. */
export function listingPlanLines(p: AppleListingPlan): string[] {
const v = p.versionPlan;
const out = [`Version ${p.version} (${platformLabel(p.platform)}; App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${platformLabel(x.platform)}`).join(', ') : `no ${platformLabel(p.platform)} version yet`})`];
out.push(` ${v.action === 'refuse' ? 'REFUSE' : v.action.toUpperCase()}: ${v.reason}`);
for (const ch of v.changes) out.push(` ${ch.field}: ${v.action === 'create' ? short(ch.to) : `${short(ch.from)} -> ${short(ch.to)}`}`);
if (p.build) out.push(`Build: ${p.build.action === 'refuse' ? 'REFUSE: ' : ''}${p.build.reason}`);
out.push(`App info ${p.appInfo.state ? `(${p.appInfo.state})` : ''}: name, subtitle, privacy policy URL per locale · ${counts(p.appInfo.locales)}`);
for (const l of p.appInfo.locales) out.push(...itemLines(l.key, l));
if (p.versionLocales) {
out.push(`Version texts: description, keywords, promotional text, what's new, support / marketing URL · ${counts(p.versionLocales)}`);
for (const l of p.versionLocales) out.push(...itemLines(l.key, l));
} else if (v.action === 'refuse') {
out.push('Version texts: not planned (the version is refused)');
} else {
out.push(`Version texts (applied after the version is created): ${Object.keys(p.wanted).join(', ')}`);
}
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
for (const n of p.notes) out.push(`Note: ${n}`);
for (const h of p.mentions ?? []) out.push(`REFUSE: ${hitLine(h)}`);
return out;
}
async function applyLocales(c: Asc, type: string, parentRel: string, parentType: string, parentId: string, listPath: string, plan: ItemPlan[]): Promise<void> {
const patch = async (key: string, id: string, changes: ItemPlan['changes']) => {
await c.asc('PATCH', `/v1/${type}/${id}`, { data: { type, id, attributes: Object.fromEntries(changes.map(ch => [ch.field, ch.to ?? ''])) } });
log('apple', `${type} ${key}: updated ${changes.map(ch => ch.field).join(', ')}`);
};
for (const l of plan) {
if (l.action === 'create') {
// a locale is unique per parent: a 409 (or a lost answer) finds the existing one, which is then updated, never created twice
const made = await createOnce<J>({ what: `${type} ${l.key}`, tag: 'apple', unique: true,
find: async () => (await c.all(`${listPath}?limit=200`)).find((x: J) => x.attributes.locale === l.key) ?? null,
create: async () => (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { locale: l.key, ...l.attributes }, relationships: { [parentRel]: { data: { type: parentType, id: parentId } } } } })).data });
if (made.created) { log('apple', `${type} ${l.key}: created`); continue; }
const again = planItem(l.key, { id: made.value.id, attributes: made.value.attributes ?? {} }, l.attributes);
if (again.action === 'update') await patch(l.key, made.value.id, again.changes);
} else if (l.action === 'update') await patch(l.key, l.id!, l.changes);
}
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
if (a._[0] === 'pull') return pull(ctx);
const version = a.required('version', 'x.y.z', 'storebound apple status shows the editable version');
const dry = a.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
// the platform: --platform, else the one where this version string is editable, else the one with an editable
// version; several -> ask (an app's iOS 0.8.7 can be live while its macOS 0.8.7 is rejected)
const isEd = (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState);
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), [(x: J) => x.attributes.versionString === version && isEd(x), isEd], undefined, `an editable version (${version})`);
const p = await planListing(ctx, c, appId, version, platform);
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : '',
(() => { try { refuseMentions(p.mentions, 'apple listing'); return ''; } catch (e) { return (e as Error).message; } })()].filter(Boolean);
if (a.has('json')) printJson({ dry, ...p, refused });
else for (const line of listingPlanLines(p)) console.log(line);
if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); }
if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
if (!p.changes) { done('apple', 'listing already up to date'); return; }
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
const vp = p.versionPlan;
let versionId = vp.id;
if (vp.action === 'create') {
// look again right before the create (another run, or a lost answer), then the journal for the list lag
const made = await findOrCreate<J>({ what: `App Store version ${version}`, tag: 'apple', unique: true,
find: async () => (await c.all(versionsPath(appId, p.platform)))
.find((x: J) => x.attributes.versionString === version && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState)) ?? null,
journal: { root, key: `apple:${appId}:version:${p.platform}:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise<J | null>, idOf: v => v.id },
create: async () => (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions',
attributes: Object.fromEntries(Object.entries(vp.attributes)), relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
versionId = made.value.id;
if (made.created) log('apple', `version ${version} created`);
else {
const { platform: _p, ...want } = vp.attributes;
const again = planItem(version, { id: versionId!, attributes: made.value.attributes ?? {} }, want);
if (again.action === 'update') await c.asc('PATCH', `/v1/appStoreVersions/${versionId}`, { data: { type: 'appStoreVersions', id: versionId, attributes: Object.fromEntries(again.changes.map(ch => [ch.field, ch.to])) } });
log('apple', `version ${version} exists already (an earlier run made it): using it${again.action === 'update' ? `, updated ${again.changes.map(ch => ch.field).join(', ')}` : ''}`);
}
} else if (vp.action === 'update') {
await c.asc('PATCH', `/v1/appStoreVersions/${versionId}`, { data: { type: 'appStoreVersions', id: versionId, attributes: Object.fromEntries(vp.changes.map(ch => [ch.field, ch.to])) } });
log('apple', `version: ${vp.changes.map(ch => `${ch.field} ${ch.from ?? '(none)'} -> ${ch.to}`).join(', ')}`);
}
if (p.build?.action === 'attach') {
await c.asc('PATCH', `/v1/appStoreVersions/${versionId}/relationships/build`, { data: { type: 'builds', id: p.build.id } });
log('apple', `build ${version} (${p.build.number}) attached`);
}
await applyLocales(c, 'appInfoLocalizations', 'appInfo', 'appInfos', p.appInfo.id, `/v1/appInfos/${p.appInfo.id}/appInfoLocalizations`, p.appInfo.locales);
const vlPath = `/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations`;
const vl = p.versionLocales ?? planLocales(await c.all(`${vlPath}?limit=200`), p.wanted);
await applyLocales(c, 'appStoreVersionLocalizations', 'appStoreVersion', 'appStoreVersions', versionId!, vlPath, vl);
done('apple', 'listing done');
}