diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index 7eb176f..d99b83b 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -1,5 +1,5 @@ // App-level App Store settings (idempotent), from apple.info in storebound.config.ts: -// storebound apple appinfo [--dry] [--json] +// 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) @@ -22,14 +22,52 @@ const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubca 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 } +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, '')); } -async function planAppInfo(ctx: Ctx, c: Awaited>, appId: string): Promise { +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[] = []; @@ -48,22 +86,47 @@ async function planAppInfo(ctx: Ctx, c: Awaited>, a 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[] = []; + 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`); - if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) { + 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'); - freePricePointId = free.id; + 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 items.push(planItem('price', { attributes: { schedule: 'exists' } }, { schedule: 'exists' })); + } else { + const now = currentBasePrice(await c.maybe(`/v1/apps/${appId}/appPriceSchedule/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 = (await c.all('/v1/territories?limit=200')).map(t => t.id); + territories = all; items.push(planItem('availability', null, { territories: `${territories.length} (all)`, availableInNewTerritories: 'true' })); - } else items.push(planItem('availability', { attributes: { availability: 'exists' } }, { availability: 'exists' })); + } 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])]; + } + } } - return { appInfoId: appInfo.id, items, freePricePointId, territories, changes: items.filter(i => i.action !== 'same').length }; + 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 { @@ -73,9 +136,10 @@ export async function run(ctx: Ctx): Promise { 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); + 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; } diff --git a/cli/apple/review-details.ts b/cli/apple/review-details.ts index ab9404b..123cc50 100644 --- a/cli/apple/review-details.ts +++ b/cli/apple/review-details.ts @@ -8,12 +8,30 @@ import { refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; -import { findReviewContact, noContactMessage, reviewDemoAccount } from '../lib/credentials'; +import { findReviewContact, maskSecret, maskUser, noContactMessage, reviewDemoAccount } from '../lib/credentials'; import { reviewDetailPlan } from '../lib/review-details'; import { done, log } from '../lib/log'; import { createOnce } from '../lib/upsert'; import { ascClient } from './asc'; +type Attrs = Record; +const shown = (k: string, v: unknown): string => { + if (v === null || v === undefined || v === '') return '(empty)'; + const t = String(v); + if (k === 'demoAccountPassword') return maskSecret(t); + if (k === 'demoAccountName' || k === 'contactEmail') return maskUser(t); + if (k === 'contactPhone') return `${t.slice(0, 3)}***`; + if (k === 'notes') return `${new TextEncoder().encode(t).length} bytes`; + return t; +}; + +/** Changed review-detail keys as `key: old -> new` lines (secrets, phone, email masked; notes as byte counts). A missing + * detail (cur null) lists every key as new. Pure. */ +export function reviewDetailDiff(cur: Attrs | null, want: Attrs): string[] { + return Object.entries(want).filter(([k, v]) => !cur || (cur[k] ?? null) !== (v ?? null)) + .map(([k, v]) => `${k}: ${cur ? shown(k, cur[k]) : '(none)'} -> ${shown(k, v)}`); +} + export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)'); @@ -40,9 +58,14 @@ export async function run(ctx: Ctx): Promise { if (!contact) throw new Error(noContactMessage(root, checked)); const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root)); - if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; } const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null; const cur = await read(); + if (ctx.args.has('dry')) { + const lines = reviewDetailDiff(cur?.attributes ?? null, attributes); + log('apple', `would ${cur ? (lines.length ? 'update' : 'keep') : 'create'} review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); + for (const l of lines) console.log(` ${l}`); + return; + } // one review detail per version: only changed fields are sent; a new one is created once (a 409 finds it) const changed = cur ? Object.fromEntries(Object.entries(attributes).filter(([k, v]) => (cur.attributes?.[k] ?? null) !== (v ?? null))) : attributes; if (cur && !Object.keys(changed).length) { done('apple', `review details on ${version.attributes.versionString} up to date (${summary})`); return; } diff --git a/tests/appinfo-drift.test.ts b/tests/appinfo-drift.test.ts new file mode 100644 index 0000000..8eaaee6 --- /dev/null +++ b/tests/appinfo-drift.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from 'bun:test'; +import { availableTerritories, blockedItems, currentBasePrice, planAvailability } from '../cli/apple/appinfo'; +import { reviewDetailDiff } from '../cli/apple/review-details'; + +const price = (id: string, startDate: string | null, point: string) => ({ type: 'appPrices', id, attributes: { startDate }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: point } } } }); +const point = (id: string, customerPrice: string) => ({ type: 'appPricePoints', id, attributes: { customerPrice } }); + +describe('currentBasePrice', () => { + test('paid price is shown as a number', () => { + expect(currentBasePrice({ data: [price('a', null, 'p1')], included: [point('p1', '4.99')] })).toBe('4.99'); + }); + test('zero is Free', () => { + expect(currentBasePrice({ data: [price('a', null, 'p1')], included: [point('p1', '0.0')] })).toBe('Free'); + }); + test('latest started price wins, future ones are ignored', () => { + const page = { data: [price('a', null, 'p1'), price('b', '2026-01-01', 'p2'), price('c', '2099-01-01', 'p3')], included: [point('p1', '0'), point('p2', '1.99'), point('p3', '9.99')] }; + expect(currentBasePrice(page, '2026-06-01')).toBe('1.99'); + }); + test('unreadable gives null', () => { + expect(currentBasePrice(null)).toBeNull(); + expect(currentBasePrice({ data: [price('a', null, 'zz')], included: [] })).toBeNull(); + }); +}); + +describe('availability', () => { + const ta = (t: string, available: boolean) => ({ attributes: { available }, relationships: { territory: { data: { id: t } } } }); + test('availableTerritories lists only available ones across pages', () => { + expect(availableTerritories([{ data: [ta('AAA', true), ta('BBB', false)] }, { data: [ta('CCC', true)] }])).toEqual(['AAA', 'CCC']); + }); + test('same set and flag: no difference', () => { + expect(planAvailability({ availableInNewTerritories: true, available: ['A', 'B'] }, ['A', 'B']).differs).toBe(false); + }); + test('missing territories are additions', () => { + const p = planAvailability({ availableInNewTerritories: true, available: ['A'] }, ['A', 'B', 'C']); + expect(p).toMatchObject({ add: ['B', 'C'], remove: [], differs: true, from: '1 territories', to: '3 territories' }); + }); + test('extra territories are removals; flag off is a change', () => { + const p = planAvailability({ availableInNewTerritories: false, available: ['A', 'X'] }, ['A']); + expect(p.remove).toEqual(['X']); + expect(p.newFlagChange).toBe(true); + }); + test('blockedItems', () => { + expect(blockedItems({ priceDiffers: true, removes: true, allowPrice: false, allowAvailability: false })).toEqual(['price', 'availability removal']); + expect(blockedItems({ priceDiffers: true, removes: true, allowPrice: true, allowAvailability: true })).toEqual([]); + expect(blockedItems({ priceDiffers: false, removes: false, allowPrice: false, allowAvailability: false })).toEqual([]); + }); +}); + +describe('reviewDetailDiff', () => { + const want = { contactPhone: '+15550001111', contactEmail: 'someone@example.com', demoAccountRequired: true, demoAccountName: 'demo-user', demoAccountPassword: 'hunter2', notes: 'Hello' }; + test('lists only changed keys and masks secrets', () => { + const lines = reviewDetailDiff({ ...want, demoAccountPassword: 'old-secret', notes: 'Hi' }, want); + expect(lines).toHaveLength(2); + expect(lines.join('\n')).not.toContain('hunter2'); + expect(lines.join('\n')).not.toContain('old-secret'); + expect(lines[0]).toBe('demoAccountPassword: ******** -> ********'); + expect(lines[1]).toBe('notes: 2 bytes -> 5 bytes'); + }); + test('nothing changed gives no lines', () => { + expect(reviewDetailDiff({ ...want }, want)).toEqual([]); + }); + test('new detail lists everything, phone and email masked', () => { + const lines = reviewDetailDiff(null, want); + expect(lines).toHaveLength(6); + expect(lines.join('\n')).not.toContain('5550001111'); + expect(lines.join('\n')).not.toContain('someone@example.com'); + }); +});