// Listing text straight on the stores, without listing files: for store-only apps in the desktop (no app folder, so // nothing to save into) and for a quick fix of one language. // storebound listing live [--json] // the live texts per language: Google Play title / short / full (one throwaway edit, deleted), App Store name / // subtitle (the app info being prepared, else the live one) and description / keywords / promotional text / what's // new (the editable version, else the newest) // storebound listing set --store play|apple --lang --fields '{"short":"..."}' [--dry] [--json] // changes only the given fields of one language. Every run reads the store first and prints old -> new per field; // --dry stops there; the real run asks in a terminal. Google Play: one edit, PATCH of that listing (other fields, // the promo video and the other languages stay), committed. App Store: PATCH of the localization (name, subtitle: // the app info being prepared; promotional text: the editable version, else the live one, which App Store // Connect allows; description, keywords, what's new: the editable version only). A missing language is created. // Checks before anything is sent: the store limits and the rules of `check` / `scan` (lib/listing-rules.ts); an error // stops the run. The URLs, contact and app details are not touched here (`play listing` / `apple listing` send them // from the config of an app folder). import { appleLocaleIssues, type FieldIssue, playLangIssues } from './lib/listing-rules'; import type { AppleCopy } from './lib/copy'; import type { PlatformAllow } from './lib/cross-store'; 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 './apple/asc'; import { playClient } from './play/client'; import { stdinIsTTY } from './lib/tty'; const TAG = 'listing'; export const PLAY_API_FIELD = { title: 'title', short: 'shortDescription', full: 'fullDescription' } as const; export type PlayTextField = keyof typeof PLAY_API_FIELD; export const APPLE_INFO_FIELDS = ['name', 'subtitle'] as const; export const APPLE_VERSION_FIELDS = ['description', 'keywords', 'promotionalText', 'whatsNew'] as const; export interface LiveListings { play: { language: string; title: string; short: string; full: string }[] | null; apple: { version: string | null; state: string | null; editable: boolean; locales: { locale: string; values: AppleCopy }[] } | null; errors: { play?: string; apple?: string }; } export interface FieldPlan { field: string; from: string | null; to: string } export interface SetPlan { store: 'play' | 'apple'; lang: string; create: boolean; changes: FieldPlan[]; same: string[]; issues: FieldIssue[]; refused: string | null; target?: string } /** Old -> new for the given fields of one language (an empty and a missing value are the same). Pure. */ export function setPlan(store: 'play' | 'apple', lang: string, current: Record | null, want: Record, o: { appName: string; editable?: boolean; allow?: readonly PlatformAllow[] }): SetPlan { const changes: FieldPlan[] = []; const same: string[] = []; for (const [f, v] of Object.entries(want)) { const from = current?.[f] ?? null; if ((from ?? '') === v.trim()) same.push(f); else changes.push({ field: f, from, to: v.trim() }); } const merged = { ...(current ?? {}), ...Object.fromEntries(Object.entries(want).map(([k, v]) => [k, v.trim()])) }; // only the fields this run sends are judged (a store value it leaves alone is not this run's problem) const sent = new Set(Object.keys(want)); const all = store === 'play' ? playLangIssues(lang, { title: merged.title || undefined, short: merged.short ?? '', full: merged.full ?? '' }, o.appName, o.allow) : appleLocaleIssues(lang, merged as AppleCopy, o.appName, o.allow); const issues = all.filter(i => sent.has(i.field) && !(i.level === 'error' && /is required$/.test(i.msg) && !(want[i.field] !== undefined && !want[i.field]!.trim()))); let refused: string | null = null; if (store === 'apple' && o.editable === false && changes.some(c => c.field !== 'promotionalText' && (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field))) refused = 'no editable App Store version: description, keywords and what\'s new change with a new version (Releases, or apple listing --version)'; return { store, lang, create: !current, changes, same, issues, refused }; } async function playLive(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const now = await play.read(async id => (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []); return now.map(l => ({ language: l.language, title: l.title ?? '', short: l.shortDescription ?? '', full: l.fullDescription ?? '' })).sort((a, b) => a.language.localeCompare(b.language)); } interface AppleRefs { c: Awaited>; version: J | null; editable: boolean; info: J } async function appleRefs(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const ev = await c.editableVersion(); const versions = ev ? [ev] : await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`); const version = ev ?? [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')))[0] ?? null; const info = await c.editableAppInfo(); return { c, version, editable: !!ev && EDITABLE.has(ev.attributes.appVersionState ?? ev.attributes.appStoreState), info }; } async function appleLive(ctx: Ctx): Promise> { const r = await appleRefs(ctx); const by: Record = {}; for (const l of await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`)) by[l.attributes.locale] = { name: l.attributes.name ?? '', subtitle: l.attributes.subtitle ?? '' }; if (r.version) for (const l of await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`)) { const at = l.attributes; by[at.locale] = { ...by[at.locale], description: at.description ?? '', keywords: at.keywords ?? '', promotionalText: at.promotionalText ?? '', whatsNew: at.whatsNew ?? '' }; } return { version: r.version?.attributes.versionString ?? null, state: r.version ? (r.version.attributes.appVersionState ?? r.version.attributes.appStoreState) : null, editable: r.editable, locales: Object.entries(by).sort(([a], [b]) => a.localeCompare(b)).map(([locale, values]) => ({ locale, values })), }; } async function live(ctx: Ctx): Promise { const { config } = ctx.loaded; const out: LiveListings = { play: null, apple: null, errors: {} }; await Promise.all([ config.play ? playLive(ctx).then(v => { out.play = v; }, e => { out.errors.play = (e as Error).message.split('\n')[0]!; }) : null, config.apple ? appleLive(ctx).then(v => { out.apple = v; }, e => { out.errors.apple = (e as Error).message.split('\n')[0]!; }) : null, ]); if (ctx.args.has('json')) { printJson(out); return; } for (const l of out.play ?? []) log(TAG, `Google Play ${l.language}: title ${l.title.length}, short ${l.short.length}, full ${l.full.length} characters`); for (const l of out.apple?.locales ?? []) log(TAG, `App Store ${l.locale}: ${Object.entries(l.values).filter(([, v]) => v).map(([k]) => k).join(', ')}`); for (const [s, e] of Object.entries(out.errors)) warn(TAG, `${s}: ${e}`); } const cut = (s: string | null): string => (s === null ? '(none)' : `"${s.length > 60 ? `${s.slice(0, 57)}...` : s}"`.replace(/\n/g, ' ')); function show(ctx: Ctx, p: SetPlan): void { const where = `${p.store === 'play' ? 'Google Play' : 'App Store'} ${p.lang}${p.target ? ` (${p.target})` : ''}`; if (ctx.args.has('json') && process.env.STOREBOUND_MACHINE !== '1') return; log(TAG, `${where}: ${p.create ? 'new language' : 'update'}`); for (const c of p.changes) console.log(` ${c.field}: ${cut(c.from)} -> ${cut(c.to)}`); for (const s of p.same) console.log(` ${s}: same`); for (const i of p.issues) console.log(` ${i.level === 'error' ? 'ERROR' : i.level === 'warn' ? 'warn ' : 'hint '} ${i.field}: ${i.msg}`); if (p.refused) console.log(` refused: ${p.refused}`); } async function set(ctx: Ctx): Promise { const a = ctx.args; const { config } = ctx.loaded; const store = a.get('store'); if (store !== 'play' && store !== 'apple') throw new Error('--store play or apple'); const lang = a.get('lang') ?? ''; if (!/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang)) throw new Error('--lang: a store language code (en-US, tr-TR, zh-Hans)'); let want: Record; try { want = JSON.parse(a.get('fields') ?? '') as Record; } catch { throw new Error('--fields: JSON, e.g. {"short":"..."}'); } const allowed: readonly string[] = store === 'play' ? Object.keys(PLAY_API_FIELD) : [...APPLE_INFO_FIELDS, ...APPLE_VERSION_FIELDS]; if (!want || typeof want !== 'object' || Array.isArray(want) || !Object.keys(want).length) throw new Error('--fields: at least one field'); for (const [k, v] of Object.entries(want)) { if (!allowed.includes(k)) throw new Error(`--fields: ${store} has no text field ${k} here (${allowed.join(', ')})`); if (typeof v !== 'string' || v.length > 20_000) throw new Error(`--fields.${k}: text`); } const dry = a.has('dry'); if (store === 'play') { const playName = config.play?.name ?? config.app.name; const now = await playLive(ctx); const cur = now?.find(l => l.language === lang) ?? null; const p = setPlan('play', lang, cur ? { title: cur.title, short: cur.short, full: cur.full } : null, want, { appName: playName, allow: config.listing?.allowPlatformMentions }); show(ctx, p); const errors = p.issues.filter(i => i.level === 'error').length; if (a.has('json')) printJson({ ...p, dry, written: false }); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; } if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } const { root } = ctx.loaded; const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const body: Record = { language: lang }; for (const c of p.changes) body[PLAY_API_FIELD[c.field as PlayTextField]] = c.to; if (p.create && !body.title) body.title = playName; await play.edit(async id => { if (p.create) await play.call('PUT', `${play.API}/edits/${id}/listings/${lang}`, { title: playName, shortDescription: '', fullDescription: '', ...body }); else await play.call('PATCH', `${play.API}/edits/${id}/listings/${lang}`, body); }, { notSentForReview: ctx.args.has('not-sent-for-review'), summary: `listing ${lang}: ${p.changes.map(c => c.field).join(', ')}` }); done(TAG, `Google Play ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (not public until Google reviews the change)`); return; } const r = await appleRefs(ctx); const infoLocs = await r.c.all(`/v1/appInfos/${r.info.id}/appInfoLocalizations?limit=200`); const verLocs = r.version ? await r.c.all(`/v1/appStoreVersions/${r.version.id}/appStoreVersionLocalizations?limit=200`) : []; const il = infoLocs.find((x: J) => x.attributes.locale === lang) ?? null; const vl = verLocs.find((x: J) => x.attributes.locale === lang) ?? null; const cur: Record | null = il || vl ? { name: il?.attributes.name ?? '', subtitle: il?.attributes.subtitle ?? '', description: vl?.attributes.description ?? '', keywords: vl?.attributes.keywords ?? '', promotionalText: vl?.attributes.promotionalText ?? '', whatsNew: vl?.attributes.whatsNew ?? '', } : null; const appName = config.apple?.name ?? config.app.name; const p = { ...setPlan('apple', lang, cur, want, { appName, editable: r.editable, allow: config.listing?.allowPlatformMentions }), target: r.version ? `version ${r.version.attributes.versionString}, ${r.version.attributes.appVersionState ?? r.version.attributes.appStoreState}` : 'no version' }; show(ctx, p); const errors = p.issues.filter(i => i.level === 'error').length; if (a.has('json')) printJson({ ...p, dry, written: false }); if (p.refused) throw new Error(p.refused); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; } if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); if (Object.keys(info).length) { if (il) await r.c.asc('PATCH', `/v1/appInfoLocalizations/${il.id}`, { data: { type: 'appInfoLocalizations', id: il.id, attributes: info } }); else await r.c.asc('POST', '/v1/appInfoLocalizations', { data: { type: 'appInfoLocalizations', attributes: { locale: lang, name: info.name ?? appName, ...info }, relationships: { appInfo: { data: { type: 'appInfos', id: r.info.id } } } } }); } if (Object.keys(ver).length) { if (!r.version) throw new Error('no App Store version to put the texts on'); if (vl) await r.c.asc('PATCH', `/v1/appStoreVersionLocalizations/${vl.id}`, { data: { type: 'appStoreVersionLocalizations', id: vl.id, attributes: ver } }); else await r.c.asc('POST', '/v1/appStoreVersionLocalizations', { data: { type: 'appStoreVersionLocalizations', attributes: { locale: lang, ...ver }, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: r.version.id } } } } }); } done(TAG, `App Store ${lang}: ${p.changes.map(c => c.field).join(', ')} changed (${p.target}; customers see it with the next approved version, promotional text at once)`); } export async function run(ctx: Ctx, sub: string): Promise { if (sub === 'live') return live(ctx); if (sub === 'set') return set(ctx); }