apple appinfo: live app price and availability compared with the config (price change needs --allow-price-changes, territory removals --allow-availability-changes); review-details --dry reads the store and shows old -> new (masked)
This commit is contained in:
parent
f7ff52dfdd
commit
f2b841d735
3 changed files with 168 additions and 13 deletions
|
|
@ -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<string, string>();
|
||||
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<ReturnType<typeof ascClient>>, appId: string): Promise<AppInfoPlan> {
|
||||
export async function planAppInfo(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string, opts: { allowPrice?: boolean; allowAvailability?: boolean } = {}): Promise<AppInfoPlan> {
|
||||
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<ReturnType<typeof ascClient>>, 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<string> => {
|
||||
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<void> {
|
||||
|
|
@ -73,9 +136,10 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
|||
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; }
|
||||
|
|
|
|||
68
tests/appinfo-drift.test.ts
Normal file
68
tests/appinfo-drift.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue