// App-level App Store settings (idempotent), from apple.info in storebound.config.ts: // storebound apple appinfo [--dry] // - 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 { need } from '../lib/ctx'; import { done, log } from '../lib/log'; import { ascClient } from './asc'; /** Every question of the current age rating declaration at its "nothing" answer. */ export const AGE_RATING_NONE: Record = { advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false, gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false, matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false, profanityOrCrudeHumor: 'NONE', sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false, userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE', violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null, }; export async function run(ctx: Ctx): Promise { 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 appInfo = await c.editableAppInfo(); const say = (m: string) => log('apple', dry ? `${m} (dry)` : m); if (info.categories) { const k = info.categories; const rel = (id?: string) => ({ data: id ? { type: 'appCategories', id } : null }); if (!dry) await c.asc('PATCH', `/v1/appInfos/${appInfo.id}`, { data: { type: 'appInfos', id: appInfo.id, relationships: { primaryCategory: rel(k.primary), primarySubcategoryOne: rel(k.primarySubOne), primarySubcategoryTwo: rel(k.primarySubTwo), secondaryCategory: rel(k.secondary) } } }); say(`categories: ${[k.primary, k.primarySubOne, k.primarySubTwo].filter(Boolean).join(' / ')}${k.secondary ? `, secondary ${k.secondary}` : ''}`); } const attributes = { ...AGE_RATING_NONE, ...(info.ageRating ?? {}) }; if (!dry) await c.asc('PATCH', `/v1/ageRatingDeclarations/${appInfo.id}`, { data: { type: 'ageRatingDeclarations', id: appInfo.id, attributes } }); say(`age rating declaration: ${Object.entries(info.ageRating ?? {}).map(([q, v]) => `${q}=${v}`).join(', ') || 'all NONE'}`); if (info.contentRights) { if (!dry) await c.asc('PATCH', `/v1/apps/${appId}`, { data: { type: 'apps', id: appId, attributes: { contentRightsDeclaration: info.contentRights } } }); say(`content rights: ${info.contentRights}`); } 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'); if (!dry) 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: free.id } } } }] }); say('price: Free (USA base)'); } else log('apple', 'price schedule exists'); const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`); if (!avail?.data) { const ts: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id); if (!dry) 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 } } } })) }); say(`availability: ${ts.length} territories + new ones`); } else log('apple', 'availability exists'); } done('apple', 'appinfo done (App Privacy is UI-only: store-declarations skill)'); }