// App Store version + listing text (idempotent; re-run after editing the App Store listing file). // storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json] // 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 MANUAL; 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 { 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 type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, warn } from '../lib/log'; import { ascClient, EDITABLE, type J } from './asc'; 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 { 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 versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`); const want = a.get('version'); 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()) ?? sorted[0]; if (!v) throw new Error(want ? `no iOS version ${want} on App Store Connect` : 'no iOS version on App Store Connect yet'); 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(); const remote: Record = {}; 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; 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>; skipped: Skipped[]; notes: string[]; changes: number; } type Asc = Awaited>; async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Promise { 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(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`); const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }); 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]=IOS`))[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); return { version, 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 (App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${x.platform}`).join(', ') : 'no iOS 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}`); return out; } async function applyLocales(c: Asc, type: string, parentRel: string, parentType: string, parentId: string, listPath: string, plan: ItemPlan[]): Promise { 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({ 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 { const { config, root } = ctx.loaded; const a = ctx.args; if (a._[0] === 'pull') return pull(ctx); const version = a.get('version'); if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--include-drafts] [--dry] [--json]'); const dry = a.has('dry'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const p = await planListing(ctx, c, appId, version); const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].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 (process.stdin.isTTY && !(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({ what: `App Store version ${version}`, tag: 'apple', unique: true, find: async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)) .find((x: J) => x.attributes.versionString === version && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState)) ?? null, journal: { root, key: `apple:${appId}:version:IOS:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise, 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'); }