storebound/cli/listing-store.ts

195 lines
14 KiB
TypeScript

// 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 <code> --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 { 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<string, string> | null, want: Record<string, string>, o: { appName: string; editable?: boolean }): 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)
: appleLocaleIssues(lang, merged as AppleCopy, o.appName);
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<LiveListings['play']> {
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<ReturnType<typeof ascClient>>; version: J | null; editable: boolean; info: J }
async function appleRefs(ctx: Ctx): Promise<AppleRefs> {
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<NonNullable<LiveListings['apple']>> {
const r = await appleRefs(ctx);
const by: Record<string, AppleCopy> = {};
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<void> {
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<void> {
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<string, string>;
try { want = JSON.parse(a.get('fields') ?? '') as Record<string, string>; } 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 });
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<string, string> = { 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);
});
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<string, string> | 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 }), 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<void> {
if (sub === 'live') return live(ctx);
if (sub === 'set') return set(ctx);
}