storebound/cli/apple/appinfo.ts

112 lines
7.8 KiB
TypeScript

// App-level App Store settings (idempotent), from apple.info in storebound.config.ts:
// storebound apple appinfo [--dry] [--json]
// 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 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';
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 }
/** The plan as text lines. Pure. */
export function appInfoPlanLines(p: AppInfoPlan): string[] {
return p.items.flatMap(i => itemLines(i.key, i, ''));
}
async function planAppInfo(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): 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[] = [];
if (info.free !== false) {
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) {
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');
freePricePointId = free.id;
items.push(planItem('price', null, { price: 'Free', baseTerritory: 'USA' }));
} else items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' }));
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
if (!avail?.data) {
territories = (await c.all('/v1/territories?limit=200')).map(t => t.id);
items.push(planItem('availability', null, { territories: `${territories.length} (all)`, availableInNewTerritories: 'true' }));
} else items.push(planItem('availability', { attributes: { availability: 'exists' } }, { availability: 'exists' }));
}
return { appInfoId: appInfo.id, items, freePricePointId, territories, changes: items.filter(i => i.action !== 'same').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');
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);
if (ctx.args.has('json')) printJson({ dry, ...p });
else for (const line of appInfoPlanLines(p)) console.log(line);
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 (process.stdin.isTTY && !(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)');
}