storebound/cli/apple/appinfo.ts

179 lines
13 KiB
TypeScript

// App-level App Store settings (idempotent), from apple.info in storebound.config.ts:
// storebound apple appinfo [--dry] [--json] [--allow-price-changes] [--allow-availability-changes]
// Every run first reads the current values and prints the plan (old -> new per field); --dry stops there, the real run
// asks in a terminal and changes only what differs.
// - categories (primary + up to two subcategories for games, secondary)
// - age rating declaration: every content question NONE / false unless overridden in apple.info.ageRating
// - content rights declaration
// - price Free (USA base) and availability in every territory + new ones (when apple.info.free !== false)
// App Privacy ("nutrition label") has no API: web UI only (store-declarations skill).
import { refuseTemplate } from '../lib/template-answers';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { type ItemPlan, itemLines, planItem } from '../lib/listing-plan';
import { done, log, printJson, warn } from '../lib/log';
import { ascClient } from './asc';
export { AGE_RATING_NONE } from '../lib/store-catalogs';
import { AGE_RATING_NONE } from '../lib/store-catalogs';
import { stdinIsTTY } from '../lib/tty';
const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const;
const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/** The plan of one `apple appinfo` run, from read-only GETs. */
export interface AppInfoPlan { appInfoId: string; items: ItemPlan[]; freePricePointId: string | null; territories: string[]; changes: number;
/** Item keys that differ but are NOT applied (price without --allow-price-changes, territory removal without --allow-availability-changes). */
blocked: string[] }
type J = any; // eslint-disable-line @typescript-eslint/no-explicit-any
/** The manual base price of an app price schedule as a label ('Free' or '4.99'), from GET appPriceSchedule/manualPrices
* ?include=appPricePoint. The current price is the one without a start date or the latest one already started. Pure. */
export function currentBasePrice(page: { data?: J[]; included?: J[] } | null | undefined, today: string = new Date().toISOString().slice(0, 10)): string | null {
const points = new Map<string, string>();
for (const x of Array.isArray(page?.included) ? page!.included! : []) if (x.type === 'appPricePoints') points.set(x.id, String(x.attributes?.customerPrice ?? ''));
const started = (Array.isArray(page?.data) ? page!.data! : []).filter(p => !p.attributes?.startDate || String(p.attributes.startDate) <= today)
.sort((a, b) => String(a.attributes?.startDate ?? '').localeCompare(String(b.attributes?.startDate ?? '')));
const cur = started[started.length - 1];
const raw = cur ? points.get(cur.relationships?.appPricePoint?.data?.id) : undefined;
if (raw === undefined || raw === '' || Number.isNaN(Number(raw))) return null;
return Number(raw) === 0 ? 'Free' : String(Number(raw));
}
export interface AvailabilityNow { availableInNewTerritories: boolean; available: string[] }
export interface AvailabilityPlan { add: string[]; remove: string[]; newFlagChange: boolean; differs: boolean; from: string; to: string }
/** Territory ids that are available now, from the pages of /v2/appAvailabilities/{id}/territoryAvailabilities. Pure. */
export function availableTerritories(pages: { data?: J[] }[]): string[] {
return pages.flatMap(p => p.data ?? []).filter(t => t.attributes?.available === true).map(t => String(t.relationships?.territory?.data?.id ?? '')).filter(Boolean);
}
/** Current availability against the wanted one (the listed territories, availableInNewTerritories true). Pure. */
export function planAvailability(cur: AvailabilityNow, wanted: string[]): AvailabilityPlan {
const have = new Set(cur.available), want = new Set(wanted);
const add = wanted.filter(t => !have.has(t)), remove = cur.available.filter(t => !want.has(t));
const newFlagChange = !cur.availableInNewTerritories;
return { add, remove, newFlagChange, differs: !!(add.length || remove.length || newFlagChange), from: `${cur.available.length} territories`, to: `${wanted.length} territories` };
}
/** Differing items that are held back: money needs --allow-price-changes, territory removal --allow-availability-changes. Pure. */
export function blockedItems(o: { priceDiffers: boolean; removes: boolean; allowPrice: boolean; allowAvailability: boolean }): string[] {
return [...(o.priceDiffers && !o.allowPrice ? ['price'] : []), ...(o.removes && !o.allowAvailability ? ['availability removal'] : [])];
}
/** The plan as text lines. Pure. */
export function appInfoPlanLines(p: AppInfoPlan): string[] {
return p.items.flatMap(i => itemLines(i.key, i, ''));
}
export async function planAppInfo(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string, opts: { allowPrice?: boolean; allowAvailability?: boolean } = {}): Promise<AppInfoPlan> {
const info = ctx.loaded.config.apple?.info ?? {};
const appInfo = await c.editableAppInfo();
const items: ItemPlan[] = [];
if (info.categories) {
const k = info.categories;
const cur = await c.maybe(`/v1/appInfos/${appInfo.id}?include=${CATEGORY_RELS.join(',')}`);
const rel = cur?.data?.relationships ?? appInfo.relationships ?? {};
const want = { primaryCategory: k.primary, primarySubcategoryOne: k.primarySubOne ?? '', primarySubcategoryTwo: k.primarySubTwo ?? '', secondaryCategory: k.secondary ?? '' };
items.push(planItem('categories', { attributes: Object.fromEntries(CATEGORY_RELS.map(r => [r, str(rel[r]?.data?.id)])) }, want));
}
const attributes = { ...AGE_RATING_NONE, ...(info.ageRating ?? {}) };
const age = await c.maybe(`/v1/appInfos/${appInfo.id}/ageRatingDeclaration`);
items.push(planItem('age rating declaration', age?.data ? { attributes: Object.fromEntries(Object.keys(attributes).map(q => [q, str(age.data.attributes?.[q])])) } : null,
Object.fromEntries(Object.entries(attributes).map(([q, v]) => [q, str(v)]))));
if (info.contentRights) {
const app = await c.maybe(`/v1/apps/${appId}`);
items.push(planItem('content rights', { attributes: { contentRightsDeclaration: str(app?.data?.attributes?.contentRightsDeclaration) } }, { contentRightsDeclaration: info.contentRights }));
}
let freePricePointId: string | null = null, territories: string[] = [], priceDiffers = false, removes = false;
if (info.free !== false) {
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
const findFree = async (): Promise<string> => {
const free = (await c.all(`/v1/apps/${appId}/appPricePoints?filter[territory]=USA&limit=200`)).find(p => Number(p.attributes.customerPrice) === 0);
if (!free) throw new Error('no free price point for USA');
return free.id as string;
};
if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) {
freePricePointId = await findFree();
items.push(planItem('price', null, { price: 'Free', baseTerritory: 'USA' }));
} else {
const now = currentBasePrice(await c.maybe(`/v1/appPriceSchedules/${appId}/manualPrices?include=appPricePoint&limit=50`));
if (now === null) items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' }));
else {
const it = planItem('price', { attributes: { price: now } }, { price: 'Free' });
items.push(it);
if (it.action !== 'same') { priceDiffers = true; if (opts.allowPrice) freePricePointId = await findFree(); }
}
}
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
const all = (await c.all('/v1/territories?limit=200')).map(t => t.id as string);
if (!avail?.data) {
territories = all;
items.push(planItem('availability', null, { territories: `${territories.length} (all)`, availableInNewTerritories: 'true' }));
} else {
const pages: J[] = [];
for (let url: string | null = `/v2/appAvailabilities/${avail.data.id}/territoryAvailabilities?include=territory&limit=200`; url;) { const pg: J = await c.asc('GET', url); pages.push(pg); url = pg.links?.next ?? null; }
const have = availableTerritories(pages);
const ap = planAvailability({ availableInNewTerritories: avail.data.attributes?.availableInNewTerritories === true, available: have }, all);
items.push(planItem('availability', { attributes: { availability: ap.from, availableInNewTerritories: String(!ap.newFlagChange) } }, { availability: ap.differs ? ap.to : ap.from, availableInNewTerritories: 'true' }));
if (ap.differs) {
removes = ap.remove.length > 0;
// additions go ahead; removals only with the flag (otherwise today's set plus the additions is sent)
territories = opts.allowAvailability || !removes ? all : [...new Set([...have, ...all])];
}
}
}
const blocked = blockedItems({ priceDiffers, removes, allowPrice: !!opts.allowPrice, allowAvailability: !!opts.allowAvailability });
const held = (i: ItemPlan) => i.key === 'price' && blocked.includes('price');
return { appInfoId: appInfo.id, items, freePricePointId, territories, blocked, changes: items.filter(i => i.action !== 'same' && !held(i)).length };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const info = config.apple?.info ?? {};
const dry = ctx.args.has('dry');
refuseTemplate(config, ['ageRating'], 'apple appinfo (age rating declaration)');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const p = await planAppInfo(ctx, c, appId, { allowPrice: ctx.args.has('allow-price-changes'), allowAvailability: ctx.args.has('allow-availability-changes') });
if (ctx.args.has('json')) printJson({ dry, ...p });
else for (const line of appInfoPlanLines(p)) console.log(line);
for (const b of p.blocked) warn('apple', b === 'price' ? "price differs from the config: not changed (live price; re-run with --allow-price-changes after the owner's yes)" : "availability would remove territories: not removed (re-run with --allow-availability-changes after the owner's yes)");
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', 'appinfo already up to date (App Privacy is UI-only: store-declarations skill)'); return; }
if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
const changed = (key: string) => p.items.some(i => i.key === key && i.action !== 'same');
if (info.categories && changed('categories')) {
const k = info.categories;
const rel = (id?: string) => ({ data: id ? { type: 'appCategories', id } : null });
await c.asc('PATCH', `/v1/appInfos/${p.appInfoId}`, { data: { type: 'appInfos', id: p.appInfoId, relationships: {
primaryCategory: rel(k.primary), primarySubcategoryOne: rel(k.primarySubOne), primarySubcategoryTwo: rel(k.primarySubTwo), secondaryCategory: rel(k.secondary) } } });
log('apple', `categories: ${[k.primary, k.primarySubOne, k.primarySubTwo].filter(Boolean).join(' / ')}${k.secondary ? `, secondary ${k.secondary}` : ''}`);
}
if (changed('age rating declaration')) {
const attributes = { ...AGE_RATING_NONE, ...(info.ageRating ?? {}) };
await c.asc('PATCH', `/v1/ageRatingDeclarations/${p.appInfoId}`, { data: { type: 'ageRatingDeclarations', id: p.appInfoId, attributes } });
log('apple', `age rating declaration: ${Object.entries(info.ageRating ?? {}).map(([q, v]) => `${q}=${v}`).join(', ') || 'all NONE'}`);
}
if (info.contentRights && changed('content rights')) {
await c.asc('PATCH', `/v1/apps/${appId}`, { data: { type: 'apps', id: appId, attributes: { contentRightsDeclaration: info.contentRights } } });
log('apple', `content rights: ${info.contentRights}`);
}
if (p.freePricePointId) {
await c.asc('POST', '/v1/appPriceSchedules', { data: { type: 'appPriceSchedules', relationships: {
app: { data: { type: 'apps', id: appId } }, baseTerritory: { data: { type: 'territories', id: 'USA' } },
manualPrices: { data: [{ type: 'appPrices', id: '${free}' }] } } },
included: [{ type: 'appPrices', id: '${free}', attributes: { startDate: null }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: p.freePricePointId } } } }] });
log('apple', 'price: Free (USA base)');
}
if (p.territories.length) {
const ts = p.territories;
await c.asc('POST', '/v2/appAvailabilities', { data: { type: 'appAvailabilities', attributes: { availableInNewTerritories: true },
relationships: { app: { data: { type: 'apps', id: appId } }, territoryAvailabilities: { data: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}' })) } } },
included: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}', attributes: { available: true }, relationships: { territory: { data: { type: 'territories', id: t } } } })) });
log('apple', `availability: ${ts.length} territories + new ones`);
}
done('apple', 'appinfo done (App Privacy is UI-only: store-declarations skill)');
}