// 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(); 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>, appId: string, opts: { allowPrice?: boolean; allowAvailability?: boolean } = {}): Promise { 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 => { 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 { 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)'); }