// 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 { 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 } /** 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>, appId: string): 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[] = []; 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 { 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); 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 (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)'); }