153 lines
11 KiB
TypeScript
153 lines
11 KiB
TypeScript
// App Store custom product pages from apple.customProductPages.
|
|
// storebound apple cpp list [--json]
|
|
// storebound apple cpp create [--name "..."] [--dry] [--json] pages missing on App Store Connect + localizations
|
|
// storebound apple cpp upload --name "..." [--only en-US,tr] [--src store/renders] [--dry] screenshots (the page's variant)
|
|
// storebound apple cpp submit --name "..." [--dry] [--yes] into a review submission (the owner's step; asks)
|
|
// storebound apple cpp visible --name "..." [--off] [--dry] [--yes] show / hide an approved page (asks)
|
|
// create and upload change nothing customers see (MCP tools, dry by default). A page's screenshots come from the
|
|
// renders of its shots variant (<renders>/<variant>/<lang>/ios69 ...), MD5-idempotent like apple screenshots.
|
|
// Pure parts and sources: cli/lib/cpp-events.ts.
|
|
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import type { Ctx } from '../lib/ctx';
|
|
import { byAppleLocale, confirm, need } from '../lib/ctx';
|
|
import { CPP_EDITABLE, CPP_LIMITS, cppCreateBody, cppLocales, type CustomProductPageDef, planCppLocales, reviewItemReq } from '../lib/cpp-events';
|
|
import { done, log, printJson, warn } from '../lib/log';
|
|
import { textTable } from '../lib/table';
|
|
import { numberedImages } from '../play/images';
|
|
import { variantRoot } from '../shots/lib';
|
|
import { type Asc, ascClient, type J } from './asc';
|
|
import { describeReq, execute, one, type Req } from './jsonapi';
|
|
import { addItem, draftSubmission, findDraft } from './submission';
|
|
import { findOrCreate, getById } from '../lib/upsert';
|
|
import { displaySets, syncScreenshotSet } from './screenshots';
|
|
|
|
const TAG = 'cpp';
|
|
const USAGE = 'usage: storebound apple cpp list | create [--name n] | upload --name n | submit --name n | visible --name n [--off]';
|
|
|
|
interface Page { id: string; name: string; url?: string; visible?: boolean; version?: { id: string; state: string; deepLink?: string | null } }
|
|
|
|
async function pagesOf(c: Asc, appId: string): Promise<Page[]> {
|
|
const out: Page[] = [];
|
|
for (const p of await c.all(`/v1/apps/${appId}/appCustomProductPages?limit=200`)) {
|
|
const vs = await c.all(`/v1/appCustomProductPages/${p.id}/appCustomProductPageVersions?limit=50`);
|
|
const v = vs.find((x: J) => CPP_EDITABLE.has(x.attributes.state)) ?? vs.at(-1);
|
|
out.push({ id: p.id, name: p.attributes.name, url: p.attributes.url, visible: p.attributes.visible, ...(v ? { version: { id: v.id, state: v.attributes.state, deepLink: v.attributes.deepLink } } : {}) });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
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?.customProductPages ?? [];
|
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
|
const appId = await c.app();
|
|
const pages = await pagesOf(c, appId);
|
|
const pick = (): { def: CustomProductPageDef; page: Page | undefined } => {
|
|
const name = need(a.get('name'), '--name "<page name>"');
|
|
const def = defs.find(d => d.name === name);
|
|
if (!def) throw new Error(`no custom product page "${name}" in apple.customProductPages`);
|
|
return { def, page: pages.find(p => p.name === name) };
|
|
};
|
|
|
|
if (sub === 'list') {
|
|
const rows = [...pages.map(p => [p.name, p.visible ? 'visible' : 'hidden', p.version?.state ?? '', defs.some(d => d.name === p.name) ? 'config' : '', p.url ?? '']),
|
|
...defs.filter(d => !pages.some(p => p.name === d.name)).map(d => [d.name, '', 'not created', 'config', ''])];
|
|
if (a.has('json')) { printJson({ pages, missing: defs.filter(d => !pages.some(p => p.name === d.name)).map(d => d.name), limit: CPP_LIMITS.pages }); return; }
|
|
console.log(rows.length ? textTable(['page', 'visible', 'version', 'source', 'url'], rows) : 'no custom product pages');
|
|
done(TAG, `${pages.length} of ${CPP_LIMITS.pages} custom product pages used`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'create') {
|
|
const only = a.get('name');
|
|
const reqs: Req[] = [];
|
|
for (const d of defs.filter(x => !only || x.name === only)) {
|
|
const locs = cppLocales(d, config.languages);
|
|
const page = pages.find(p => p.name === d.name);
|
|
if (!page) { reqs.push({ method: 'POST', path: '/v1/appCustomProductPages', note: `page "${d.name}" with ${locs.length} locale(s)`, body: cppCreateBody(appId, d, locs) }); continue; }
|
|
if (!page.version || !CPP_EDITABLE.has(page.version.state)) { log(TAG, `"${d.name}": version ${page.version?.state ?? 'missing'}, not editable (a new version: App Store Connect)`); continue; }
|
|
const have = (await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)).map((l: J) => ({ id: l.id, locale: l.attributes.locale, promotionalText: l.attributes.promotionalText }));
|
|
reqs.push(...planCppLocales(page.version.id, have, locs));
|
|
if ((d.deepLink ?? null) !== (page.version.deepLink ?? null) && d.deepLink) reqs.push({ method: 'PATCH', path: `/v1/appCustomProductPageVersions/${page.version.id}`, note: `"${d.name}" deep link`,
|
|
body: { data: { type: 'appCustomProductPageVersions', id: page.version.id, attributes: { deepLink: d.deepLink } } } });
|
|
}
|
|
if (pages.length + reqs.filter(r => r.path === '/v1/appCustomProductPages').length > CPP_LIMITS.pages) throw new Error(`more than ${CPP_LIMITS.pages} custom product pages`);
|
|
if (a.has('json')) printJson({ dryRun: dry, requests: reqs.map(r => describeReq(r)) });
|
|
if (!reqs.length) { done(TAG, 'custom product pages up to date'); return; }
|
|
if (dry) { await execute(c.asc, reqs, { dry, tag: TAG }); done(TAG, `dry run: ${reqs.length} change(s)`); return; }
|
|
// page names are not known to be unique on Apple's side: each new page 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 r of reqs) {
|
|
if (r.path !== '/v1/appCustomProductPages') { await execute(c.asc, [r], { dry, tag: TAG }); continue; }
|
|
const name = (r.body as J).data.attributes.name as string;
|
|
const made = await findOrCreate<J>({ what: `custom product page "${name}"`, tag: TAG,
|
|
find: async () => (await c.all(`/v1/apps/${appId}/appCustomProductPages?limit=200`)).find((p: J) => p.attributes.name === name) ?? null,
|
|
journal: { root, key: `apple:${appId}:cpp:${name}`, byId: id => getById(c.asc, `/v1/appCustomProductPages/${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'}`);
|
|
}
|
|
done(TAG, dry ? `dry run: ${reqs.length} change(s)` : `${reqs.length} change(s); next: storebound apple cpp upload --name "<page>"`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'upload') {
|
|
const { def, page } = pick();
|
|
if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`);
|
|
if (!CPP_EDITABLE.has(page.version.state)) throw new Error(`"${def.name}": version ${page.version.state}, screenshots can only change on an editable version`);
|
|
const src = variantRoot(resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), def.variant, config.shots?.variants);
|
|
const only = a.list('only');
|
|
const langOf = byAppleLocale(config.languages);
|
|
let sets = 0;
|
|
for (const loc of await c.all(`/v1/appCustomProductPageVersions/${page.version.id}/appCustomProductPageLocalizations?limit=200`)) {
|
|
const locale: string = loc.attributes.locale;
|
|
if (only.length && !only.includes(locale)) continue;
|
|
const lang = langOf.get(locale);
|
|
if (!lang) { warn(TAG, `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
|
const existing = await c.all(`/v1/appCustomProductPageLocalizations/${loc.id}/appScreenshotSets?limit=50`);
|
|
for (const [dir, type] of Object.entries(displaySets(config.apple))) {
|
|
const d = join(src, lang.id, dir);
|
|
const files = existsSync(d) ? numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10) : [];
|
|
if (!files.length) { warn(TAG, `${locale}: nothing in ${d}, skipped`); continue; }
|
|
await syncScreenshotSet(c, { rel: 'appCustomProductPageLocalization', type: 'appCustomProductPageLocalizations', id: loc.id }, existing, type, files, `"${def.name}" ${locale} ${type}`, dry);
|
|
sets++;
|
|
}
|
|
}
|
|
done(TAG, `${sets} screenshot set(s) ${dry ? 'checked (dry run)' : 'synced'} for "${def.name}"`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'submit') {
|
|
const { def, page } = pick();
|
|
if (!page?.version) throw new Error(`"${def.name}" is not on App Store Connect yet (storebound apple cpp create)`);
|
|
if (!['PREPARE_FOR_SUBMISSION', 'REJECTED'].includes(page.version.state)) { done(TAG, `"${def.name}": version is ${page.version.state}, nothing to submit`); return; }
|
|
const draft = await findDraft(c, appId);
|
|
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) } } } };
|
|
const item = reviewItemReq(draft ?? '${submission}', 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`);
|
|
if (dry) { await execute(c.asc, [...(draft ? [] : [create]), item, { method: 'PATCH', path: `/v1/reviewSubmissions/${draft ?? '${submission}'}`, note: 'submit for App Review' }], { dry, tag: TAG }); return; }
|
|
if (!(await confirm(ctx, `Send custom product page "${def.name}" to App Review now?`))) return;
|
|
const sid = draft ?? await draftSubmission(c, appId, root, TAG);
|
|
await addItem(c, sid, 'appCustomProductPageVersion', page.version.id, `custom product page "${def.name}"`, TAG);
|
|
await c.asc('PATCH', `/v1/reviewSubmissions/${sid}`, { data: { type: 'reviewSubmissions', id: sid, attributes: { submitted: true } } });
|
|
done(TAG, `"${def.name}" submitted (review submission ${sid}); after approval: storebound apple cpp visible --name "${def.name}"`);
|
|
return;
|
|
}
|
|
|
|
if (sub === 'visible') {
|
|
const { def, page } = pick();
|
|
if (!page) throw new Error(`"${def.name}" is not on App Store Connect yet`);
|
|
const visible = !a.has('off');
|
|
if (page.visible === visible) { done(TAG, `"${def.name}" is already ${visible ? 'visible' : 'hidden'}`); return; }
|
|
const req: Req = { method: 'PATCH', path: `/v1/appCustomProductPages/${page.id}`, note: `"${def.name}" ${visible ? 'visible' : 'hidden'}`, body: { data: { type: 'appCustomProductPages', id: page.id, attributes: { visible } } } };
|
|
if (dry) { await execute(c.asc, [req], { dry, tag: TAG }); return; }
|
|
if (!(await confirm(ctx, `${visible ? 'Show' : 'Hide'} custom product page "${def.name}" on the App Store?`))) return;
|
|
await execute(c.asc, [req], { dry: false, tag: TAG });
|
|
done(TAG, `"${def.name}" ${visible ? 'visible' : 'hidden'}${page.url ? ` (${page.url})` : ''}`);
|
|
return;
|
|
}
|
|
throw new Error(`unknown cpp command "${sub}"\n${USAGE}`);
|
|
}
|