apple listing, play listing, apple appinfo: the dry run prints a real plan (version decision, old -> new per field, draft locales skipped); the real run asks and applies exactly it

This commit is contained in:
CRTkafa 2026-10-12 03:29:38 +08:00
parent 5afe4613c4
commit b88648499e
13 changed files with 613 additions and 130 deletions

View file

@ -2,6 +2,15 @@
## Unreleased ## Unreleased
- `apple listing --dry`, `play listing --dry` and `apple appinfo --dry` print a real plan (also `--json`), read-only:
the version decision of `apple listing` (keep, update the editable version's string while PREPARE_FOR_SUBMISSION,
create, or refuse: number taken, not higher than a reviewed version, another version in review / pending release),
the build to attach, and per locale / language create or update with old -> new per field (long text cut with its
length, unchanged fields summarized); app info name / subtitle / privacy URL, categories, age rating and content
rights the same way. Locales whose section is `status=draft` or `status=translate` (`listing translate`) are skipped
with the reason; `--include-drafts` sends them. The real run prints the same plan, asks in a terminal and applies
exactly it (unchanged localizations are no longer PATCHed, Play PUTs only changed languages); what's new is left out
on an app's first version.
- `apple.name`: the App Store name in the primary locale (at most 30 characters, validated) for when the plain name is - `apple.name`: the App Store name in the primary locale (at most 30 characters, validated) for when the plain name is
taken on the App Store ("already being used"). `app.name` stays the Play and on-device name. `console fill taken on the App Store ("already being used"). `app.name` stays the Play and on-device name. `console fill
apple-app-record` fills Name from it (else `app.name`), and `apple listing`, `apple listing pull` (a pulled name equal apple-app-record` fills Name from it (else `app.name`), and `apple listing`, `apple listing pull` (a pulled name equal

View file

@ -340,7 +340,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| command | kind | what | | command | kind | what |
|---|---|---| |---|---|---|
| `play listing` | API | app details, title / short / full description per language, promo video URL (`play.video`) | | `play listing [--include-drafts]` | API | app details, title / short / full description per language, promo video URL (`play.video`); prints the plan first (old -> new per field, draft languages skipped), `--dry` stops there, the real run asks and sends only changes |
| `play listing pull [--write]` | API, read-only + local | live listings back into `store/listing.md`: diff first, writes only with `--write` | | `play listing pull [--write]` | API, read-only + local | live listings back into `store/listing.md`: diff first, writes only with `--write` |
| `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language | | `play images [--variant b]` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
| `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate | | `play products` | API | one-time products: regional prices from `convertRegionPrices` + your overrides, activate |
@ -361,7 +361,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| command | kind | what | | command | kind | what |
|---|---|---| |---|---|---|
| `apple bundle` | API | register the bundle id, find the app record | | `apple bundle` | API | register the bundle id, find the app record |
| `apple listing --version x.y.z [--build N]` | API | version (manual release), the build with exactly that number, texts per locale (name: the locale's `### name`, else `apple.name`, else `app.name`) | | `apple listing --version x.y.z [--build N] [--include-drafts]` | API | prints the plan first (version: keep / update / create / refuse with the reason; per locale create / update, old -> new per field; draft locales skipped), `--dry` stops there, the real run asks and applies exactly it: version (manual release), the build with exactly that number, texts per locale (name: the locale's `### name`, else `apple.name`, else `app.name`) |
| `apple listing pull [--version x.y.z] [--write]` | API, read-only + local | live texts back into `store/appstore.md`: diff first, writes only with `--write` | | `apple listing pull [--version x.y.z] [--write]` | API, read-only + local | live texts back into `store/appstore.md`: diff first, writes only with `--write` |
| `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent | | `apple screenshots [--variant b]` | API | 6.9" + 6.5" (+ 13" iPad with `apple.ipad`, + 6.3" with `apple.iphoneMedium`) per locale, MD5-idempotent |
| `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame | | `apple previews [--poster 5]` | API | app preview videos per locale, checked first, MD5-idempotent, poster frame |
@ -374,7 +374,7 @@ run in the background with a log (`storebound shots render > .storebound/render.
| `apple cpp list \| create \| upload \| submit \| visible` | API | custom product pages from the config and a shots variant; submit / visible ask first | | `apple cpp list \| create \| upload \| submit \| visible` | API | custom product pages from the config and a shots variant; submit / visible ask first |
| `apple events list \| create \| upload \| submit` | API | in-app events with schedule, localizations, card and details images; submit asks first | | `apple events list \| create \| upload \| submit` | API | in-app events with schedule, localizations, card and details images; submit asks first |
| `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) | | `apple subscriptions [--csv] [--codes]` | API | groups, subscriptions, levels, localizations, availability, equalized prices + overrides + CSV (scheduled changes), introductory and promotional offers, offer code campaigns, review screenshots (plan first, asks; custom codes only with `--codes`) |
| `apple appinfo` | API | categories, age rating, content rights, Free, territories | | `apple appinfo` | API | categories, age rating, content rights, Free, territories; plan first (old -> new), only changes are sent |
| `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) | | `apple review-details` | API | App Review contact, demo account (from `.storebound/`, masked), notes (+ sign-in steps, where account deletion is) |
| `apple encryption` | API | export compliance answer from `apple.usesNonExemptEncryption` (never guessed) | | `apple encryption` | API | export compliance answer from `apple.usesNonExemptEncryption` (never guessed) |
| `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) | | `apple release --phased on\|pause\|resume\|complete`, `--manual-release` | API | phased release, release an approved version (resume / complete / release ask first) |

View file

@ -26,8 +26,9 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appEventScreenshots_updateInstance': 'apple/events', 'appEventScreenshots_updateInstance': 'apple/events',
'appEvents_createInstance': 'apple/events', 'appEvents_createInstance': 'apple/events',
'appEvents_localizations_getToManyRelated': 'apple/events', 'appEvents_localizations_getToManyRelated': 'apple/events',
'appInfos_ageRatingDeclaration_getToOneRelated': 'validate', 'appInfos_ageRatingDeclaration_getToOneRelated': 'apple/appinfo validate',
'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate', 'appInfos_appInfoLocalizations_getToManyRelated': 'apple/listing validate',
'appInfos_getInstance': 'apple/appinfo',
'appInfos_updateInstance': 'apple/appinfo', 'appInfos_updateInstance': 'apple/appinfo',
'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews', 'appPreviewSets_appPreviews_getToManyRelated': 'apple/previews',
'appPreviewSets_createInstance': 'apple/previews', 'appPreviewSets_createInstance': 'apple/previews',
@ -57,7 +58,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate', 'appStoreVersions_appStoreReviewDetail_getToOneRelated': 'apple/review-details validate',
'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate', 'appStoreVersions_appStoreVersionLocalizations_getToManyRelated': 'apple/listing apple/previews apple/screenshots validate',
'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status', 'appStoreVersions_appStoreVersionPhasedRelease_getToOneRelated': 'apple/release apple/status',
'appStoreVersions_build_getToOneRelated': 'validate', 'appStoreVersions_build_getToOneRelated': 'apple/listing validate',
'appStoreVersions_build_updateToOneRelationship': 'apple/listing', 'appStoreVersions_build_updateToOneRelationship': 'apple/listing',
'appStoreVersions_createInstance': 'apple/listing', 'appStoreVersions_createInstance': 'apple/listing',
'appStoreVersions_updateInstance': 'apple/listing', 'appStoreVersions_updateInstance': 'apple/listing',
@ -76,7 +77,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'apps_betaGroups_getToManyRelated': 'apple/testflight', 'apps_betaGroups_getToManyRelated': 'apple/testflight',
'apps_customerReviews_getToManyRelated': 'reviews', 'apps_customerReviews_getToManyRelated': 'reviews',
'apps_getCollection': 'apple/asc apple/bundle apps lib/store-apps', 'apps_getCollection': 'apple/asc apple/bundle apps lib/store-apps',
'apps_getInstance': 'apple/status', 'apps_getInstance': 'apple/appinfo apple/status',
'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate', 'apps_inAppPurchasesV2_getToManyRelated': 'apple/iap apple/iap-review apple/iap-submit apple/offer-codes apple/promoted apple/status catalog prices validate',
'apps_perfPowerMetrics_getToManyRelated': 'health', 'apps_perfPowerMetrics_getToManyRelated': 'health',
'apps_promotedPurchases_getToManyRelated': 'apple/promoted', 'apps_promotedPurchases_getToManyRelated': 'apple/promoted',

View file

@ -1,13 +1,16 @@
// App-level App Store settings (idempotent), from apple.info in storebound.config.ts: // App-level App Store settings (idempotent), from apple.info in storebound.config.ts:
// storebound apple appinfo [--dry] // 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) // - categories (primary + up to two subcategories for games, secondary)
// - age rating declaration: every content question NONE / false unless overridden in apple.info.ageRating // - age rating declaration: every content question NONE / false unless overridden in apple.info.ageRating
// - content rights declaration // - content rights declaration
// - price Free (USA base) and availability in every territory + new ones (when apple.info.free !== false) // - 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). // App Privacy ("nutrition label") has no API: web UI only (store-declarations skill).
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log } from '../lib/log'; import { type ItemPlan, itemLines, planItem } from '../lib/listing-plan';
import { done, log, printJson, warn } from '../lib/log';
import { ascClient } from './asc'; import { ascClient } from './asc';
/** Every question of the current age rating declaration at its "nothing" answer. */ /** Every question of the current age rating declaration at its "nothing" answer. */
@ -20,52 +23,97 @@ export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null, violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
}; };
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<ReturnType<typeof ascClient>>, appId: string): Promise<AppInfoPlan> {
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<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const info = config.apple?.info ?? {}; const info = config.apple?.info ?? {};
const dry = ctx.args.has('dry'); const dry = ctx.args.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app(); const appId = await c.app();
const appInfo = await c.editableAppInfo(); const p = await planAppInfo(ctx, c, appId);
const say = (m: string) => log('apple', dry ? `${m} (dry)` : m); 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 (process.stdin.isTTY && !(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) { if (info.categories && changed('categories')) {
const k = info.categories; const k = info.categories;
const rel = (id?: string) => ({ data: id ? { type: 'appCategories', id } : null }); 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: { 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) } } }); 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}` : ''}`); 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 ?? {}) }; const attributes = { ...AGE_RATING_NONE, ...(info.ageRating ?? {}) };
if (!dry) await c.asc('PATCH', `/v1/ageRatingDeclarations/${appInfo.id}`, { data: { type: 'ageRatingDeclarations', id: appInfo.id, attributes } }); await c.asc('PATCH', `/v1/ageRatingDeclarations/${p.appInfoId}`, { data: { type: 'ageRatingDeclarations', id: p.appInfoId, attributes } });
say(`age rating declaration: ${Object.entries(info.ageRating ?? {}).map(([q, v]) => `${q}=${v}`).join(', ') || 'all NONE'}`); log('apple', `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.contentRights && changed('content rights')) {
if (info.free !== false) { await c.asc('PATCH', `/v1/apps/${appId}`, { data: { type: 'apps', id: appId, attributes: { contentRightsDeclaration: info.contentRights } } });
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`); log('apple', `content rights: ${info.contentRights}`);
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 (p.freePricePointId) {
if (!free) throw new Error('no free price point for USA'); await c.asc('POST', '/v1/appPriceSchedules', { data: { type: 'appPriceSchedules', relationships: {
if (!dry) await c.asc('POST', '/v1/appPriceSchedules', { data: { type: 'appPriceSchedules', relationships: { app: { data: { type: 'apps', id: appId } }, baseTerritory: { data: { type: 'territories', id: 'USA' } },
app: { data: { type: 'apps', id: appId } }, baseTerritory: { data: { type: 'territories', id: 'USA' } }, manualPrices: { data: [{ type: 'appPrices', id: '${free}' }] } } },
manualPrices: { data: [{ type: 'appPrices', id: '${free}' }] } } }, included: [{ type: 'appPrices', id: '${free}', attributes: { startDate: null }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: p.freePricePointId } } } }] });
included: [{ type: 'appPrices', id: '${free}', attributes: { startDate: null }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: free.id } } } }] }); log('apple', 'price: Free (USA base)');
say('price: Free (USA base)'); }
} else log('apple', 'price schedule exists'); if (p.territories.length) {
const ts = p.territories;
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`); await c.asc('POST', '/v2/appAvailabilities', { data: { type: 'appAvailabilities', attributes: { availableInNewTerritories: true },
if (!avail?.data) { relationships: { app: { data: { type: 'apps', id: appId } }, territoryAvailabilities: { data: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}' })) } } },
const ts: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id); included: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}', attributes: { available: true }, relationships: { territory: { data: { type: 'territories', id: t } } } })) });
if (!dry) await c.asc('POST', '/v2/appAvailabilities', { data: { type: 'appAvailabilities', attributes: { availableInNewTerritories: true }, log('apple', `availability: ${ts.length} territories + new ones`);
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)'); done('apple', 'appinfo done (App Privacy is UI-only: store-declarations skill)');
} }

View file

@ -1,5 +1,9 @@
// App Store version + listing text (idempotent; re-run after editing the App Store listing file). // App Store version + listing text (idempotent; re-run after editing the App Store listing file).
// storebound apple listing --version 1.0.0 [--build 3] [--dry] // storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]
// Every run first reads App Store Connect and prints the plan: the version (keep, update the editable one, create, or
// refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there
// (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or
// status=translate (listing translate) are skipped unless --include-drafts.
// - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached // - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale // - appInfoLocalizations: name, subtitle, privacy policy URL per locale
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version), // - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
@ -12,9 +16,10 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { type AppleCopy, parseAppStoreListing } from '../lib/copy'; import { type AppleCopy, parseAppStoreListing } from '../lib/copy';
import { compactDiff } from '../lib/diff'; import { compactDiff } from '../lib/diff';
import { mergeAppleListing, overLimits } from '../lib/listing-pull'; import { mergeAppleListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planLocales, planVersion, short, type Skipped, type VersionPlan } from '../lib/listing-plan';
import { appleName } from '../lib/config'; import { appleName } from '../lib/config';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log'; import { done, log, printJson, warn } from '../lib/log';
import { ascClient, type J } from './asc'; import { ascClient, type J } from './asc';
@ -25,22 +30,6 @@ export function readAppleCopy(ctx: Ctx) {
return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l))); return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l)));
} }
async function upsertLocs(c: Awaited<ReturnType<typeof ascClient>>, list: string, type: string, parentRel: string, parentType: string, parentId: string,
want: Record<string, Record<string, string | undefined>>): Promise<void> {
const have = new Map((await c.all(`${list}?limit=200`)).map((l: J) => [l.attributes.locale, l] as const));
for (const [locale, raw] of Object.entries(want)) {
const attributes = Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
const cur = have.get(locale);
if (!cur) {
await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { locale, ...attributes }, relationships: { [parentRel]: { data: { type: parentType, id: parentId } } } } });
log('apple', `${type} ${locale}: created`);
} else if (Object.entries(attributes).some(([k, v]) => cur.attributes[k] !== v)) {
await c.asc('PATCH', `/v1/${type}/${cur.id}`, { data: { type, id: cur.id, attributes } });
log('apple', `${type} ${locale}: updated`);
} else log('apple', `${type} ${locale}: up to date`);
}
}
/** apple listing pull: live texts -> diff -> (with --write) the App Store listing file. */ /** apple listing pull: live texts -> diff -> (with --write) the App Store listing file. */
async function pull(ctx: Ctx): Promise<void> { async function pull(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
@ -82,45 +71,137 @@ async function pull(ctx: Ctx): Promise<void> {
done('apple', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new locales ${r.added.join(', ')}` : ''}`); done('apple', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new locales ${r.added.join(', ')}` : ''}`);
} }
/** The plan of one `apple listing --version` run, from read-only GETs. */
export interface AppleListingPlan {
version: string;
versionPlan: VersionPlan;
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
appInfo: { id: string; state: string; locales: ItemPlan[] };
/** null: the version is created first; App Store Connect copies the previous version's texts, then these are applied. */
versionLocales: ItemPlan[] | null;
wanted: Record<string, Record<string, string | undefined>>;
skipped: Skipped[];
notes: string[];
changes: number;
}
type Asc = Awaited<ReturnType<typeof ascClient>>;
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Promise<AppleListingPlan> {
const { config, resolve } = ctx.loaded;
const app = config.app;
const listing = readAppleCopy(ctx);
const md = readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8');
const drafts = draftSections(md, 'apple');
const include = ctx.args.has('include-drafts');
const skipped: Skipped[] = [];
const notes: string[] = [];
for (const l of Object.keys(listing)) {
const st = drafts.get(l);
if (!st) continue;
if (include) notes.push(`${l}: ${st === 'draft' ? 'machine-translated draft' : 'untranslated (status=translate)'} included (--include-drafts)`);
else { skipped.push({ key: l, reason: draftReason(st) }); delete listing[l]; }
}
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
const rows = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' });
let build: AppleListingPlan['build'] = null;
const number = ctx.args.get('build');
if (number) {
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=IOS`))[0];
const cur = versionPlan.id ? (await c.maybe(`/v1/appStoreVersions/${versionPlan.id}/build`))?.data : null;
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
build = !b ? { number, id: null, current, action: 'refuse', reason: `build ${version} (${number}) not found (still uploading? check TestFlight)` }
: b.attributes.processingState !== 'VALID' ? { number, id: b.id, current, action: 'refuse', reason: `build ${number} is ${b.attributes.processingState}; wait until VALID` }
: cur?.id === b.id ? { number, id: b.id, current, action: 'same', reason: `build ${number} is already attached` }
: { number, id: b.id, current, action: 'attach', reason: `attach build ${version} (${number})${current ? ` instead of ${current}` : ''}` };
}
const info = await c.editableAppInfo();
const infoWant = Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { name: f.name ?? appleName(config), subtitle: f.subtitle, privacyPolicyUrl: app.privacyPolicyUrl }]));
const infoLocs = planLocales(await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=200`), infoWant);
if (versionPlan.first && Object.values(listing).some(f => f.whatsNew)) notes.push('what\'s new is left out: App Store Connect has no what\'s new on the first version');
const wanted = Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { description: f.description, keywords: f.keywords, promotionalText: f.promotionalText,
whatsNew: versionPlan.first ? undefined : f.whatsNew, supportUrl: app.supportUrl, marketingUrl: app.marketingUrl ?? app.website }]));
const versionLocales = versionPlan.id ? planLocales(await c.all(`/v1/appStoreVersions/${versionPlan.id}/appStoreVersionLocalizations?limit=200`), wanted) : null;
if (!versionLocales && versionPlan.action === 'create') notes.push('the new version gets the previous version\'s texts from App Store Connect; the version texts below are applied after it is created');
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
return { version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
versionLocales, wanted, skipped, notes, changes };
}
/** The plan as text lines. Pure. */
export function listingPlanLines(p: AppleListingPlan): string[] {
const v = p.versionPlan;
const out = [`Version (App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${x.platform}`).join(', ') : 'no iOS version yet'})`];
out.push(` ${v.action === 'refuse' ? 'REFUSE' : v.action.toUpperCase()}: ${v.reason}`);
for (const ch of v.changes) out.push(` ${ch.field}: ${v.action === 'create' ? short(ch.to) : `${short(ch.from)} -> ${short(ch.to)}`}`);
if (p.build) out.push(`Build: ${p.build.action === 'refuse' ? 'REFUSE: ' : ''}${p.build.reason}`);
out.push(`App info ${p.appInfo.state ? `(${p.appInfo.state})` : ''}: name, subtitle, privacy policy URL per locale · ${counts(p.appInfo.locales)}`);
for (const l of p.appInfo.locales) out.push(...itemLines(l.key, l));
if (p.versionLocales) {
out.push(`Version texts: description, keywords, promotional text, what's new, support / marketing URL · ${counts(p.versionLocales)}`);
for (const l of p.versionLocales) out.push(...itemLines(l.key, l));
} else if (v.action === 'refuse') {
out.push('Version texts: not planned (the version is refused)');
} else {
out.push(`Version texts (applied after the version is created): ${Object.keys(p.wanted).join(', ')}`);
}
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
for (const n of p.notes) out.push(`Note: ${n}`);
return out;
}
async function applyLocales(c: Asc, type: string, parentRel: string, parentType: string, parentId: string, plan: ItemPlan[]): Promise<void> {
for (const l of plan) {
if (l.action === 'create') {
await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { locale: l.key, ...l.attributes }, relationships: { [parentRel]: { data: { type: parentType, id: parentId } } } } });
log('apple', `${type} ${l.key}: created`);
} else if (l.action === 'update') {
await c.asc('PATCH', `/v1/${type}/${l.id}`, { data: { type, id: l.id, attributes: Object.fromEntries(l.changes.map(ch => [ch.field, ch.to ?? ''])) } });
log('apple', `${type} ${l.key}: updated ${l.changes.map(ch => ch.field).join(', ')}`);
}
}
}
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const a = ctx.args; const a = ctx.args;
if (a._[0] === 'pull') return pull(ctx); if (a._[0] === 'pull') return pull(ctx);
const version = a.get('version'); const version = a.get('version');
if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--dry]'); if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--include-drafts] [--dry] [--json]');
const listing = readAppleCopy(ctx); const dry = a.has('dry');
log('apple', `listing: ${Object.keys(listing).length} locales (${Object.keys(listing).join(', ')})`);
if (a.has('dry')) return;
const app = config.app;
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app(); const appId = await c.app();
const p = await planListing(ctx, c, appId, version);
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].filter(Boolean);
if (a.has('json')) printJson({ dry, ...p, refused });
else for (const line of listingPlanLines(p)) console.log(line);
if (refused.length) { if (dry) { process.exitCode = 1; warn('apple', `dry run: would refuse: ${refused.join('; ')}`); return; } throw new Error(refused.join('; ')); }
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', 'listing already up to date'); return; }
if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; }
let v = await c.editableVersion(); const vp = p.versionPlan;
if (!v) { let versionId = vp.id;
v = (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions', if (vp.action === 'create') {
attributes: { platform: 'IOS', versionString: version, copyright, releaseType: 'MANUAL' }, versionId = (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions',
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data; attributes: Object.fromEntries(Object.entries(vp.attributes)), relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id;
log('apple', `version ${version} created`); log('apple', `version ${version} created`);
} else if (v.attributes.versionString !== version || v.attributes.copyright !== copyright || v.attributes.releaseType !== 'MANUAL') { } else if (vp.action === 'update') {
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: { versionString: version, copyright, releaseType: 'MANUAL' } } }); await c.asc('PATCH', `/v1/appStoreVersions/${versionId}`, { data: { type: 'appStoreVersions', id: versionId, attributes: Object.fromEntries(vp.changes.map(ch => [ch.field, ch.to])) } });
log('apple', `version ${v.attributes.versionString} -> ${version}, copyright, release MANUAL`); log('apple', `version: ${vp.changes.map(ch => `${ch.field} ${ch.from ?? '(none)'} -> ${ch.to}`).join(', ')}`);
} }
if (p.build?.action === 'attach') {
const build = a.get('build'); await c.asc('PATCH', `/v1/appStoreVersions/${versionId}/relationships/build`, { data: { type: 'builds', id: p.build.id } });
if (build) { log('apple', `build ${version} (${p.build.number}) attached`);
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${build}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=IOS`))[0];
if (!b) throw new Error(`build ${version} (${build}) not found (still uploading? check TestFlight)`);
if (b.attributes.processingState !== 'VALID') throw new Error(`build ${build} is ${b.attributes.processingState}; wait until VALID`);
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: b.id } });
log('apple', `build ${version} (${build}) attached`);
} }
await applyLocales(c, 'appInfoLocalizations', 'appInfo', 'appInfos', p.appInfo.id, p.appInfo.locales);
const info = await c.editableAppInfo(); const vl = p.versionLocales ?? planLocales(await c.all(`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?limit=200`), p.wanted);
await upsertLocs(c, `/v1/appInfos/${info.id}/appInfoLocalizations`, 'appInfoLocalizations', 'appInfo', 'appInfos', info.id, await applyLocales(c, 'appStoreVersionLocalizations', 'appStoreVersion', 'appStoreVersions', versionId!, vl);
Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { name: f.name ?? appleName(config), subtitle: f.subtitle, privacyPolicyUrl: app.privacyPolicyUrl }])));
await upsertLocs(c, `/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations`, 'appStoreVersionLocalizations', 'appStoreVersion', 'appStoreVersions', v.id,
Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { description: f.description, keywords: f.keywords, promotionalText: f.promotionalText,
whatsNew: f.whatsNew, supportUrl: app.supportUrl, marketingUrl: app.marketingUrl ?? app.website }])));
done('apple', 'listing done'); done('apple', 'listing done');
} }

View file

@ -15,7 +15,7 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
const repeated = new Map<string, string[]>(); const repeated = new Map<string, string[]>();
const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); }; const set = (k: string, v: string) => { flags.set(k, v); repeated.set(k, [...(repeated.get(k) ?? []), v]); };
const _: string[] = []; const _: string[] = [];
const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', ...booleans]); const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', 'all-pages', 'apply', 'resume', 'force', 'include-drafts', ...booleans]);
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const a = argv[i]!; const a = argv[i]!;
if (!a.startsWith('--')) { _.push(a); continue; } if (!a.startsWith('--')) { _.push(a); continue; }

137
cli/lib/listing-plan.ts Normal file
View file

@ -0,0 +1,137 @@
// Write plans for the listing commands (apple listing, play listing, apple appinfo): what the store has now, what a
// run would send, field by field (old -> new). The dry run prints the plan; the real run applies exactly that plan.
// Pure: the commands read the store, this file decides and formats.
import { EDITABLE } from '../apple/asc';
import { appleLocaleOf, markerOf, playCodesOf, sections, type TStatus } from './listing-translate';
export interface FieldDiff { field: string; from: string | null; to: string | null }
/** One localization (or one settings object): create, update the changed fields, or leave it. */
export interface ItemPlan { key: string; action: 'create' | 'update' | 'same'; id?: string; changes: FieldDiff[]; unchanged: string[]; attributes: Record<string, string> }
export interface Skipped { key: string; reason: string }
const norm = (v: unknown): string | null => (v === undefined || v === null || v === '' ? null : String(v));
/** Create / update / same for one wanted attribute set against the current one (null when it does not exist). Only
* defined wanted values count; an empty and a missing value are the same. Pure. */
export function planItem(key: string, current: { id?: string; attributes: Record<string, unknown> } | null, want: Record<string, string | undefined>): ItemPlan {
const attributes = Object.fromEntries(Object.entries(want).filter((e): e is [string, string] => e[1] !== undefined));
if (!current) return { key, action: 'create', changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), unchanged: [], attributes };
const changes: FieldDiff[] = [], unchanged: string[] = [];
for (const [field, to] of Object.entries(attributes)) {
const from = norm(current.attributes[field]);
if (from === norm(to)) unchanged.push(field); else changes.push({ field, from, to });
}
return { key, action: changes.length ? 'update' : 'same', ...(current.id ? { id: current.id } : {}), changes, unchanged, attributes };
}
/** Localizations: every wanted locale against the ones the store has (by `locale`). Pure. */
export function planLocales(have: { id: string; attributes: Record<string, unknown> }[], want: Record<string, Record<string, string | undefined>>): ItemPlan[] {
const byLocale = new Map(have.map(h => [String(h.attributes.locale), h] as const));
return Object.entries(want).map(([locale, w]) => planItem(locale, byLocale.get(locale) ?? null, w));
}
// ---- App Store version ----
export interface VersionInfo { id: string; versionString: string; state: string; platform: string }
export interface VersionPlan {
action: 'create' | 'update' | 'keep' | 'refuse';
/** The editable version that is kept or updated. */
id?: string;
reason: string;
existing: VersionInfo[];
changes: FieldDiff[];
attributes: Record<string, string>;
/** No version of the app went through review before: what's new cannot be set on the first version. */
first: boolean;
}
/** Versions App Store Connect still lets you edit (texts, build, version string). */
export const EDITABLE_STATES = EDITABLE;
/** A version in these states blocks a new one (App Store Connect allows one version in progress). */
const IN_PROGRESS = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW', 'READY_FOR_REVIEW', 'WAITING_FOR_EXPORT_COMPLIANCE', 'PENDING_APPLE_RELEASE',
'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_APP_STORE', 'PROCESSING_FOR_DISTRIBUTION', 'ACCEPTED']);
/** -1 / 0 / 1 by numeric segments ("1.0" < "1.0.1", "1.10" > "1.9"). */
export function compareVersions(a: string, b: string): number {
const x = a.split('.').map(Number), y = b.split('.').map(Number);
for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] ?? 0) - (y[i] ?? 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
/** Version rows of GET /v1/apps/{id}/appStoreVersions. */
export const versionInfos = (rows: { id: string; attributes: Record<string, unknown> }[]): VersionInfo[] =>
rows.map(r => ({ id: r.id, versionString: String(r.attributes.versionString ?? ''), state: String(r.attributes.appVersionState ?? r.attributes.appStoreState ?? ''), platform: String(r.attributes.platform ?? 'IOS') }));
/**
* What `apple listing --version <want>` does with the app's iOS versions: keep / update the editable one (its version
* string changes only while PREPARE_FOR_SUBMISSION), create a new one, or refuse (the number exists, is not higher than
* a version that went through review, or another version is in review / pending release). Pure.
*/
export function planVersion(rows: { id: string; attributes: Record<string, unknown> }[], want: string, attrs: { copyright: string; releaseType: string }): VersionPlan {
const existing = versionInfos(rows);
const editable = existing.find(v => EDITABLE_STATES.has(v.state));
const others = existing.filter(v => v !== editable);
const first = !others.length;
const base = { existing, first, changes: [] as FieldDiff[], attributes: {} as Record<string, string> };
const refuse = (reason: string): VersionPlan => ({ ...base, action: 'refuse', reason });
const taken = others.find(v => v.versionString === want);
if (taken) return refuse(`version ${want} already exists (${taken.state}); a new version needs a higher number`);
const higher = others.filter(v => compareVersions(v.versionString, want) >= 0).sort((a, b) => compareVersions(b.versionString, a.versionString))[0];
if (higher && (!editable || editable.versionString !== want))
return refuse(`version ${want} is not higher than ${higher.versionString} (${higher.state}); App Store versions only go up`);
if (editable) {
const cur = rows.find(r => r.id === editable.id)!.attributes;
const wantAttrs = { versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
const p = planItem(editable.id, { id: editable.id, attributes: cur }, wantAttrs);
if (editable.versionString !== want && editable.state !== 'PREPARE_FOR_SUBMISSION')
return refuse(`the editable version ${editable.versionString} is ${editable.state}; its version string changes only while PREPARE_FOR_SUBMISSION (use --version ${editable.versionString}, or change it in App Store Connect)`);
if (editable.versionString !== want) return { ...base, action: 'update', id: editable.id, changes: p.changes, attributes: p.attributes,
reason: `update the editable version ${editable.versionString} (${editable.state}) to ${want}` };
return { ...base, action: p.changes.length ? 'update' : 'keep', id: editable.id, changes: p.changes, attributes: p.attributes,
reason: p.changes.length ? `update version ${want} (${editable.state}): ${p.changes.map(c => c.field).join(', ')}` : `version ${want} (${editable.state}) is up to date` };
}
const busy = others.find(v => IN_PROGRESS.has(v.state));
if (busy) return refuse(`version ${busy.versionString} is ${busy.state}; App Store Connect allows one version in progress (wait for its release or remove it from review)`);
const attributes = { platform: 'IOS', versionString: want, copyright: attrs.copyright, releaseType: attrs.releaseType };
return { ...base, action: 'create', reason: `create version ${want} (no editable iOS version)`, changes: Object.entries(attributes).map(([field, to]) => ({ field, from: null, to })), attributes };
}
// ---- translation drafts ----
/** Locales (App Store) or Play language codes whose section is a translation draft or still untranslated, with why. */
export function draftSections(md: string, store: 'apple' | 'play'): Map<string, TStatus> {
const out = new Map<string, TStatus>();
for (const sec of sections(md)) {
const m = markerOf(sec);
if (!m) continue;
const keys = store === 'apple' ? [appleLocaleOf(sec)].filter((x): x is string => !!x) : playCodesOf(sec);
for (const k of keys) out.set(k, m.status);
}
return out;
}
export const draftReason = (s: TStatus): string => s === 'draft'
? 'machine-translated draft, not reviewed (storebound listing check, then listing reviewed --lang <id>; or --include-drafts)'
: 'not translated yet (status=translate: still the source text; translate it, or --include-drafts)';
// ---- formatting ----
/** One value for a plan line: quoted, long text cut with its length. */
export function short(v: string | null, max = 60): string {
if (v === null) return '(none)';
const flat = v.replace(/\s*\n\s*/g, ' ⏎ ');
const n = [...v].length;
return [...flat].length > max ? `"${[...flat].slice(0, max).join('')}…" (${n} chars)` : `"${flat}"`;
}
/** Plan lines of one item: a header and one line per changed field; unchanged fields summarized. */
export function itemLines(label: string, p: ItemPlan, indent = ' '): string[] {
if (p.action === 'same') return [`${indent}${label}: unchanged (${p.unchanged.length} field${p.unchanged.length === 1 ? '' : 's'})`];
const head = `${indent}${label}: ${p.action === 'create' ? 'create' : 'update'}${p.unchanged.length ? ` (${p.unchanged.length} unchanged: ${p.unchanged.join(', ')})` : ''}`;
return [head, ...p.changes.map(c => `${indent} ${c.field}: ${p.action === 'create' ? short(c.to) : `${short(c.from)} -> ${short(c.to)}`}`)];
}
export const counts = (items: ItemPlan[]): string => {
const n = (a: ItemPlan['action']) => items.filter(i => i.action === a).length;
return `${n('create')} create, ${n('update')} update, ${n('same')} unchanged`;
};

View file

@ -150,7 +150,7 @@ export const TOOLS: ToolDef[] = [
props: { since: { type: 'string', description: 'Git tag or ref (default: latest tag)' }, lang: { type: 'string' }, all_languages: { type: 'boolean', default: false } }, props: { since: { type: 'string', description: 'Git tag or ref (default: latest tag)' }, lang: { type: 'string' }, all_languages: { type: 'boolean', default: false } },
args: i => [...opt('since', i.since), ...opt('lang', i.lang), ...flag('all', i.all_languages)] }, args: i => [...opt('since', i.since), ...opt('lang', i.lang), ...flag('all', i.all_languages)] },
{ name: 'play_listing', title: 'Push Play listing text', command: ['play', 'listing'], write: true, network: true, idempotent: true, { name: 'play_listing', title: 'Push Play listing text', command: ['play', 'listing'], write: true, network: true, idempotent: true,
description: 'Google Play app details + title / short / full description per language from the listing file. Not public until the next review / publish.', description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.',
props: { only }, args: i => opt('only', i.only) }, props: { only }, args: i => opt('only', i.only) },
{ name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true, { name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true,
description: 'Replace icon, feature graphic and screenshots per Play language from the renders folder (or an A/B variant folder). Slow (minutes): prefer the terminal with a log for many languages.', description: 'Replace icon, feature graphic and screenshots per Play language from the renders folder (or an A/B variant folder). Slow (minutes): prefer the terminal with a log for many languages.',
@ -181,7 +181,7 @@ export const TOOLS: ToolDef[] = [
{ name: 'apple_iap_review', title: 'IAP review screenshots', command: ['apple', 'iap-review'], write: true, destructive: true, network: true, idempotent: true, { name: 'apple_iap_review', title: 'IAP review screenshots', command: ['apple', 'iap-review'], write: true, destructive: true, network: true, idempotent: true,
description: 'Upload (replace when changed) the App Review screenshot of every in-app purchase. Never submits.' }, description: 'Upload (replace when changed) the App Review screenshot of every in-app purchase. Never submits.' },
{ name: 'apple_listing', title: 'Push App Store version + text', command: ['apple', 'listing'], write: true, network: true, idempotent: true, { name: 'apple_listing', title: 'Push App Store version + text', command: ['apple', 'listing'], write: true, network: true, idempotent: true,
description: 'Create / update the editable version (manual release), attach a build by explicit number, and push name / subtitle / description / keywords / promo / what\'s new per locale.', description: 'Create / update the editable version (manual release), attach a build by explicit number, and push name / subtitle / description / keywords / promo / what\'s new per locale. dry_run prints the plan: version keep / update / create / refuse with the reason, per locale old -> new per field; draft locales are skipped (owner terminal run with --include-drafts).',
props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, build: { type: 'string', description: 'Exact build number to attach (use the number you just uploaded, never "latest")' }, only }, props: { version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' }, build: { type: 'string', description: 'Exact build number to attach (use the number you just uploaded, never "latest")' }, only },
required: ['version'], args: i => [...opt('version', i.version), ...opt('build', i.build), ...opt('only', i.only)] }, required: ['version'], args: i => [...opt('version', i.version), ...opt('build', i.build), ...opt('only', i.only)] },
{ name: 'apple_screenshots', title: 'Upload App Store screenshots', command: ['apple', 'screenshots'], write: true, destructive: true, network: true, idempotent: true, { name: 'apple_screenshots', title: 'Upload App Store screenshots', command: ['apple', 'screenshots'], write: true, destructive: true, network: true, idempotent: true,

View file

@ -1,7 +1,9 @@
// Play store listing text + app details in one edit. // Play store listing text + app details in one edit.
// storebound play listing [--dry] [--only en-US,de-DE] // storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]
// --dry is read-only: a throwaway edit is opened, the current details and listings are read and compared with the file, // Every run first reads Play in a throwaway edit (deleted again) and prints the plan: app details and per language
// and the edit is deleted. Nothing is written, not even into that edit. // create / update with old -> new per field. --dry stops there (nothing is written, not even into that edit); the real
// run asks in a terminal and PUTs only what the plan changes, in one committed edit. Languages whose section is
// status=draft or status=translate (listing translate) are skipped unless --include-drafts.
// App details: default language, contact email, website (must be the domain that serves app-ads.txt). // App details: default language, contact email, website (must be the domain that serves app-ads.txt).
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo // Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo
// video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with // video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with
@ -12,8 +14,9 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { parsePlayListing } from '../lib/copy'; import { parsePlayListing } from '../lib/copy';
import { compactDiff } from '../lib/diff'; import { compactDiff } from '../lib/diff';
import { mergePlayListing, overLimits } from '../lib/listing-pull'; import { mergePlayListing, overLimits } from '../lib/listing-pull';
import { counts, draftReason, draftSections, type ItemPlan, itemLines, planItem, type Skipped } from '../lib/listing-plan';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx'; import { confirm, need } from '../lib/ctx';
import { done, log, printJson, step, warn } from '../lib/log'; import { done, log, printJson, step, warn } from '../lib/log';
import { playClient } from './client'; import { playClient } from './client';
@ -75,36 +78,77 @@ async function pull(ctx: Ctx): Promise<void> {
done('play', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new languages ${r.added.join(', ')}` : ''}`); done('play', `${file}: ${r.changes.length} change(s) written${r.added.length ? `, new languages ${r.added.join(', ')}` : ''}`);
} }
/** The plan of one `play listing` run, from a read-only edit. */
export interface PlayListingPlan { details: ItemPlan; listings: ItemPlan[]; other: string[]; skipped: Skipped[]; notes: string[]; changes: number }
/** Plan from what Play has now. The listing PUT replaces the whole listing: a promo video on Play that play.video leaves
* out is shown as cleared. Pure. */
export function playPlan(curDetails: Record<string, unknown>, details: Record<string, string | undefined>, now: PlayListing[], bodies: Record<string, string>[],
o: { skipped?: Skipped[]; notes?: string[]; only?: boolean } = {}): PlayListingPlan {
const d = planItem('details', { attributes: curDetails }, details);
const byLang = new Map(now.map(l => [l.language, l]));
const listings = bodies.map(b => {
const cur = byLang.get(b.language!);
const { language, ...want } = b;
const w: Record<string, string | undefined> = { ...want, video: want.video ?? (cur?.video ? '' : undefined) };
return planItem(language!, cur ? { attributes: cur as unknown as Record<string, unknown> } : null, w);
});
const other = o.only ? [] : now.filter(l => !bodies.some(b => b.language === l.language)).map(l => l.language);
const changes = (d.action === 'same' ? 0 : 1) + listings.filter(l => l.action !== 'same').length;
return { details: d, listings, other, skipped: o.skipped ?? [], notes: o.notes ?? [], changes };
}
/** The plan as text lines. Pure. */
export function playPlanLines(p: PlayListingPlan): string[] {
const out = [...itemLines('App details (default language, contact email, website)', p.details, '')];
out.push(`Listings: title, short and full description, promo video · ${counts(p.listings)}`);
for (const l of p.listings) out.push(...itemLines(l.key, l));
if (p.other.length) out.push(`Left as they are (on Play, not in the listing file): ${p.other.join(', ')}`);
for (const s of p.skipped) out.push(`Skipped ${s.key}: ${s.reason}`);
for (const n of p.notes) out.push(`Note: ${n}`);
return out;
}
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
if (ctx.args._[0] === 'pull') return pull(ctx); const a = ctx.args;
if (a._[0] === 'pull') return pull(ctx);
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
const copy = readPlayCopy(ctx); const all = readPlayCopy(ctx);
const drafts = draftSections(readFileSync(resolve(config.play?.listing ?? 'store/listing.md'), 'utf8'), 'play');
const skipped: Skipped[] = [], notes: string[] = [];
const copy = all.filter(c => {
const st = drafts.get(c.lang);
if (!st) return true;
if (a.has('include-drafts')) { notes.push(`${c.lang}: ${st === 'draft' ? 'machine-translated draft' : 'untranslated (status=translate)'} included (--include-drafts)`); return true; }
skipped.push({ key: c.lang, reason: draftReason(st) });
return false;
});
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website }; const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
if (!copy.some(c => c.lang === details.defaultLanguage) && !ctx.args.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`); if (!all.some(c => c.lang === details.defaultLanguage) && !a.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`); if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`);
const play = await playClient(root, pkg); const play = await playClient(root, pkg);
if (ctx.args.has('dry')) { const bodies = copy.map(c => listingBody(c, config.play?.video));
await play.read(async id => { const p = await play.read(async id => {
const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`); const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const dc = detailsChanges(cur, details); const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
log('play', `details: ${dc.length ? 'would change ' + dc.join(', ') : 'unchanged'}`); return playPlan(cur, details, now, bodies, { skipped, notes, only: a.has('only') });
const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []; });
const changes = listingChanges(now, copy.map(c => listingBody(c, config.play?.video))); const dry = a.has('dry');
for (const c of changes) log('play', `${c.lang}: ${c.change === 'update' ? 'would update ' + c.fields.join(', ') : c.change === 'new' ? 'would add (new language)' : 'unchanged'}`); if (a.has('json')) printJson({ dry, ...p });
const other = now.filter(l => !copy.some(c => c.lang === l.language)).map(l => l.language); else for (const line of playPlanLines(p)) console.log(line);
if (other.length && !ctx.args.has('only')) log('play', `${other.length} languages on Play not in the listing file (left as they are): ${other.join(', ')}`); if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; }
const n = changes.filter(c => c.change !== 'same').length + (dc.length ? 1 : 0); if (!p.changes) { done('play', 'listing already up to date'); return; }
done('play', `dry run: ${n ? n + ' change(s)' : 'nothing to change'}; read-only, nothing written`); if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; }
}); const todo = p.listings.filter(l => l.action !== 'same').map(l => bodies.find(b => b.language === l.key)!);
return;
}
await play.edit(async id => { await play.edit(async id => {
await play.call('PUT', `${play.API}/edits/${id}/details`, details); if (p.details.action !== 'same') {
log('play', `details: ${details.defaultLanguage} · ${details.contactEmail} · ${details.contactWebsite}`); await play.call('PUT', `${play.API}/edits/${id}/details`, details);
for (const [i, c] of copy.entries()) { log('play', `details: ${p.details.changes.map(ch => ch.field).join(', ')}`);
await play.call('PUT', `${play.API}/edits/${id}/listings/${c.lang}`, listingBody(c, config.play?.video)); }
log('play', step(i + 1, copy.length, c.lang)); for (const [i, b] of todo.entries()) {
await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b);
log('play', step(i + 1, todo.length, b.language!));
} }
}); });
done('play', 'listing done'); done('play', 'listing done');

View file

@ -12,7 +12,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only | | API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 186 | 166 | | App Store Connect API 4.5.1 | 1303 | 826 | 477 | 187 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 | | Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 | | Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
@ -236,11 +236,11 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appInfoLocalizations_getInstance` | GET | `/v1/appInfoLocalizations/{id}` | read | | | `appInfoLocalizations_getInstance` | GET | `/v1/appInfoLocalizations/{id}` | read | |
| `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | | | `appInfoLocalizations_updateInstance` | PATCH | `/v1/appInfoLocalizations/{id}` | write (dry by default) | |
| **appInfos** (20) | | | | | | **appInfos** (20) | | | | |
| `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | validate | | `appInfos_ageRatingDeclaration_getToOneRelated` | GET | `/v1/appInfos/{id}/ageRatingDeclaration` | read | apple/appinfo, validate |
| `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | | | `appInfos_ageRatingDeclaration_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/ageRatingDeclaration` | read | |
| `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, validate | | `appInfos_appInfoLocalizations_getToManyRelated` | GET | `/v1/appInfos/{id}/appInfoLocalizations` | read | apple/listing, validate |
| `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | | | `appInfos_appInfoLocalizations_getToManyRelationship` | GET | `/v1/appInfos/{id}/relationships/appInfoLocalizations` | read | |
| `appInfos_getInstance` | GET | `/v1/appInfos/{id}` | read | | | `appInfos_getInstance` | GET | `/v1/appInfos/{id}` | read | apple/appinfo |
| `appInfos_primaryCategory_getToOneRelated` | GET | `/v1/appInfos/{id}/primaryCategory` | read | | | `appInfos_primaryCategory_getToOneRelated` | GET | `/v1/appInfos/{id}/primaryCategory` | read | |
| `appInfos_primaryCategory_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/primaryCategory` | read | | | `appInfos_primaryCategory_getToOneRelationship` | GET | `/v1/appInfos/{id}/relationships/primaryCategory` | read | |
| `appInfos_primarySubcategoryOne_getToOneRelated` | GET | `/v1/appInfos/{id}/primarySubcategoryOne` | read | | | `appInfos_primarySubcategoryOne_getToOneRelated` | GET | `/v1/appInfos/{id}/primarySubcategoryOne` | read | |
@ -377,7 +377,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | | | `appStoreVersions_appStoreVersionPhasedRelease_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionPhasedRelease` | read | |
| `appStoreVersions_appStoreVersionSubmission_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionSubmission` | read | | | `appStoreVersions_appStoreVersionSubmission_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/appStoreVersionSubmission` | read | |
| `appStoreVersions_appStoreVersionSubmission_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionSubmission` | read | | | `appStoreVersions_appStoreVersionSubmission_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/appStoreVersionSubmission` | read | |
| `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | validate | | `appStoreVersions_build_getToOneRelated` | GET | `/v1/appStoreVersions/{id}/build` | read | apple/listing, validate |
| `appStoreVersions_build_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/build` | read | | | `appStoreVersions_build_getToOneRelationship` | GET | `/v1/appStoreVersions/{id}/relationships/build` | read | |
| `appStoreVersions_build_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/build` | write (dry by default) | apple/listing | | `appStoreVersions_build_updateToOneRelationship` | PATCH | `/v1/appStoreVersions/{id}/relationships/build` | write (dry by default) | apple/listing |
| `appStoreVersions_createInstance` | POST | `/v1/appStoreVersions` | write (dry by default) | apple/listing | | `appStoreVersions_createInstance` | POST | `/v1/appStoreVersions` | write (dry by default) | apple/listing |
@ -459,7 +459,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | | | `apps_gameCenterEnabledVersions_getToManyRelated` | GET | `/v1/apps/{id}/gameCenterEnabledVersions` | read | |
| `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | | | `apps_gameCenterEnabledVersions_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/gameCenterEnabledVersions` | read | |
| `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, lib/store-apps | | `apps_getCollection` | GET | `/v1/apps` | read | apple/asc, apple/bundle, apps, lib/store-apps |
| `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/status | | `apps_getInstance` | GET | `/v1/apps/{id}` | read | apple/appinfo, apple/status |
| `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate | | `apps_inAppPurchasesV2_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchasesV2` | read | apple/iap, apple/iap-review, apple/iap-submit, apple/offer-codes, apple/promoted, apple/status, catalog, prices, validate |
| `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | | | `apps_inAppPurchasesV2_getToManyRelationship` | GET | `/v1/apps/{id}/relationships/inAppPurchasesV2` | read | |
| `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | | | `apps_inAppPurchases_getToManyRelated` | GET | `/v1/apps/{id}/inAppPurchases` | read | |

View file

@ -46,11 +46,15 @@ of a first version is the owner's click. Shared agent rules: `docs/agents-core.m
Test, Test Information and Beta App Review: the testflight skill. Sandbox testers stay UI: Users and Access > Sandbox. Test, Test Information and Beta App Review: the testflight skill. Sandbox testers stay UI: Users and Access > Sandbox.
7. **API · Version + text.** Fill `store/appstore.md`, then `storebound apple listing --version 1.0.0 --build 3` 7. **API · Version + text.** Fill `store/appstore.md`, then `storebound apple listing --version 1.0.0 --build 3`
(version, copyright, MANUAL release, the build with exactly that number, name / subtitle / privacy URL, (version, copyright, MANUAL release, the build with exactly that number, name / subtitle / privacy URL,
description / keywords / promo / URLs). Then `storebound apple release --phased on`: manual release + phased description / keywords / promo / URLs). Run it with `--dry` first and show the owner the plan: the version decision
(keep, update the editable version's string, create, or refuse with the reason), the build, and per locale create /
update with old -> new per field. Locales still marked `status=draft` / `status=translate` are skipped until
`listing reviewed` (`--include-drafts` only when the owner says so). The real run asks in a terminal and applies
exactly that plan. Then `storebound apple release --phased on`: manual release + phased
release is the default for every version (`release-management`). release is the default for every version (`release-management`).
8. **API · Screenshots.** `store-screenshots` skill, then 8. **API · Screenshots.** `store-screenshots` skill, then
`storebound apple screenshots > .storebound/asc-shots.log 2>&1 &` (6.9" + 6.5" per locale, MD5-idempotent). `storebound apple screenshots > .storebound/asc-shots.log 2>&1 &` (6.9" + 6.5" per locale, MD5-idempotent).
9. **API · App info.** `storebound apple appinfo`: categories, age rating declaration, content rights, Free price, 9. **API · App info.** `storebound apple appinfo` (`--dry` first: old -> new per field): categories, age rating declaration, content rights, Free price,
all territories. The **first** availability record of a new app may be refused by the public API (an error on all territories. The **first** availability record of a new app may be refused by the public API (an error on
POST /v2/appAvailabilities); then set it once in App Store Connect > Pricing and Availability and re-run (later runs only read it). POST /v2/appAvailabilities); then set it once in App Store Connect > Pricing and Availability and re-run (later runs only read it).
10. **API · Review details.** `storebound apple review-details` (contact from `.storebound/credentials.json` 10. **API · Review details.** `storebound apple review-details` (contact from `.storebound/credentials.json`

View file

@ -38,7 +38,9 @@ deleting products. Sending a first release for review is the owner's action in t
`--dry` (asks first; `play release app-release.aab` does the same with more track options). `--dry` (asks first; `play release app-release.aab` does the same with more track options).
Add testers (internal testing list) and **license testers** (Settings > License testing) so test purchases are free. Add testers (internal testing list) and **license testers** (Settings > License testing) so test purchases are free.
6. **API · Listing text.** Fill `store/listing.md` (one section per language), then `storebound play listing`. 6. **API · Listing text.** Fill `store/listing.md` (one section per language), then `storebound play listing`.
It also sets contact email + website (website = the domain serving app-ads.txt). It also sets contact email + website (website = the domain serving app-ads.txt). Run `--dry` first and show the
owner the plan (details and per language old -> new per field; draft languages skipped until `listing reviewed`,
`--include-drafts` only on the owner's word); the real run asks in a terminal and PUTs only what changes.
7. **API · Graphics.** Render (`store-screenshots` skill), `storebound shots jpeg`, then 7. **API · Graphics.** Render (`store-screenshots` skill), `storebound shots jpeg`, then
`storebound play images > .storebound/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language). `storebound play images > .storebound/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language).
8. **API · In-app products.** `store-iap-setup` skill (`storebound play products`). Needs a build with the BILLING 8. **API · In-app products.** `store-iap-setup` skill (`storebound play products`). Needs a build with the BILLING

157
tests/listing-plan.test.ts Normal file
View file

@ -0,0 +1,157 @@
// Write plans of apple listing, play listing and apple appinfo: the dry run reads only and prints old -> new per field,
// drafts are skipped, the version decision is explicit; the real run applies exactly the plan. Recorded answers only
// (placeholders, no network).
import { afterEach, describe, expect, test } from 'bun:test';
import { run as appInfo } from '../cli/apple/appinfo';
import { run as appleListing } from '../cli/apple/listing';
import { run as playListing } from '../cli/play/listing';
import { planItem, planVersion, short } from '../cli/lib/listing-plan';
import { ascEnv, BASE, capture, jsonOut, mockFetch, playEnv, project, restore, type Call } from './helpers/store-mock';
afterEach(restore);
const page = (data: unknown[]) => ({ data, links: { self: 'x' } });
const v = (id: string, versionString: string, appVersionState: string) => ({ type: 'appStoreVersions', id, attributes: { versionString, appVersionState, platform: 'IOS', copyright: `${new Date().getFullYear()} Example Notes`, releaseType: 'MANUAL' } });
const fence = (t: string) => '```\n' + t + '\n```';
const sec = (loc: string, f: Record<string, string>, mark = '') => `## ${loc}\n${mark}\n` + Object.entries(f).map(([k, t]) => `### ${k}\n${fence(t)}\n`).join('\n') + '\n';
const TEXT = { subtitle: 'Notes that sync', promotionalText: 'Now with lists.', keywords: 'notes,lists', description: 'Write things down.' };
const APPLE_MD = '# App Store\n\n' + sec('en-US', TEXT) + sec('de-DE', { ...TEXT, subtitle: 'Notizen, synchron', description: 'Schreib es auf.' })
+ sec('tr', { ...TEXT, subtitle: 'Senkron notlar' }, '<!-- storebound-translation from=en-US status=draft -->');
const methods = (calls: Call[]) => calls.filter(c => c.method !== 'GET').map(c => `${c.method} ${c.path.split('?')[0]}`);
describe('version decision', () => {
const attrs = { copyright: '2026 Example Notes', releaseType: 'MANUAL' };
test('update the editable version string while PREPARE_FOR_SUBMISSION', () => {
const p = planVersion([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')], '1.0.0', attrs);
expect(p).toMatchObject({ action: 'update', id: 'v-1', first: true, reason: 'update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0' });
expect(p.changes.find(c => c.field === 'versionString')).toEqual({ field: 'versionString', from: '1.0', to: '1.0.0' });
});
test('keep, create, refuse', () => {
expect(planVersion([{ ...v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'), attributes: { ...v('v-1', '1.0.0', 'x').attributes, appVersionState: 'PREPARE_FOR_SUBMISSION', copyright: '2026 Example Notes' } }], '1.0.0', attrs).action).toBe('keep');
expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')], '1.1.0', attrs)).toMatchObject({ action: 'create', first: false });
expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')], '1.0.0', attrs).reason).toContain('already exists (READY_FOR_DISTRIBUTION)');
expect(planVersion([v('v-0', '1.2.0', 'READY_FOR_DISTRIBUTION')], '1.1.0', attrs).reason).toContain('not higher than 1.2.0');
expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION'), v('v-1', '1.1.0', 'WAITING_FOR_REVIEW')], '1.2.0', attrs).reason).toContain('1.1.0 is WAITING_FOR_REVIEW');
expect(planVersion([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION'), v('v-1', '1.1', 'REJECTED')], '1.1.0', attrs)).toMatchObject({ action: 'refuse', reason: expect.stringContaining('only while PREPARE_FOR_SUBMISSION') });
expect(planVersion([], '1.0.0', attrs)).toMatchObject({ action: 'create', first: true });
});
test('field diff and long values cut with their length', () => {
expect(planItem('en-US', { attributes: { subtitle: 'A', keywords: null } }, { subtitle: 'B', keywords: '', description: undefined })).toMatchObject({ action: 'update', changes: [{ field: 'subtitle', from: 'A', to: 'B' }], unchanged: ['keywords'] });
expect(short('x'.repeat(100))).toBe(`"${'x'.repeat(60)}…" (100 chars)`);
});
});
function ascRoutes(versions: unknown[], o: { versionLocs?: unknown[]; created?: boolean } = {}) {
return mockFetch([
{ match: /^\/v1\/apps\/6400000101\/appStoreVersions\?filter\[platform\]=IOS/, answer: page(versions) },
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' } }]) },
{ match: /^\/v1\/appInfos\/ai-1\/appInfoLocalizations/, answer: page([{ type: 'appInfoLocalizations', id: 'il-en', attributes: { locale: 'en-US', name: 'Example Notes', subtitle: 'Old subtitle', privacyPolicyUrl: 'https://example.com/privacy' } }]) },
{ match: /^\/v1\/appStoreVersions\/(v-1|v-new)\/appStoreVersionLocalizations/, answer: page(o.versionLocs ?? [{ type: 'appStoreVersionLocalizations', id: 'vl-en', attributes: { locale: 'en-US', ...TEXT, whatsNew: null, supportUrl: 'https://example.com/support', marketingUrl: 'https://example.com' } }]) },
{ method: 'PATCH', match: /^\/v1\/appStoreVersions\/v-1$/, answer: { data: v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION') } },
{ method: 'POST', match: /^\/v1\/appStoreVersions$/, answer: { data: v('v-new', '1.1.0', 'PREPARE_FOR_SUBMISSION') } },
{ method: 'POST', match: /^\/v1\/(appInfoLocalizations|appStoreVersionLocalizations)$/, answer: c => ({ data: { id: 'new', attributes: (c.body as { data: { attributes: unknown } }).data.attributes } }) },
{ method: 'PATCH', match: /^\/v1\/(appInfoLocalizations|appStoreVersionLocalizations)\//, answer: { data: {} } },
]);
}
describe('apple listing plan', () => {
test('--dry --json: version update, per-locale create / update with old -> new, draft skipped; GETs only', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD });
const calls = ascRoutes([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')]);
const r = await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry', '--json'])));
expect(methods(calls)).toEqual([]);
const j = jsonOut(r.out) as any;
expect(j.versionPlan).toMatchObject({ action: 'update', reason: 'update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0' });
expect(j.versionPlan.existing).toEqual([{ id: 'v-1', versionString: '1.0', state: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }]);
expect(j.appInfo.locales.map((l: any) => `${l.key} ${l.action}`)).toEqual(['en-US update', 'de-DE create']);
expect(j.appInfo.locales[0].changes).toEqual([{ field: 'subtitle', from: 'Old subtitle', to: 'Notes that sync' }]);
expect(j.versionLocales.map((l: any) => `${l.key} ${l.action}`)).toEqual(['en-US same', 'de-DE create']);
expect(j.skipped).toEqual([{ key: 'tr', reason: expect.stringContaining('machine-translated draft') }]);
expect(j.changes).toBe(4);
const t = await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry'])));
expect(t.out).toContain('UPDATE: update the editable version 1.0 (PREPARE_FOR_SUBMISSION) to 1.0.0');
expect(t.out).toContain('subtitle: "Old subtitle" -> "Notes that sync"');
expect(t.out).toContain('en-US: unchanged (5 fields)');
expect(t.out).toContain('Skipped tr: machine-translated draft');
});
test('--include-drafts plans the draft locale too', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD });
ascRoutes([v('v-1', '1.0.0', 'PREPARE_FOR_SUBMISSION')]);
const j = jsonOut((await capture(() => appleListing(p.ctx(['--version', '1.0.0', '--dry', '--json', '--include-drafts'])))).out) as any;
expect(j.skipped).toEqual([]);
expect(j.appInfo.locales.map((l: any) => l.key)).toEqual(['en-US', 'de-DE', 'tr']);
});
test('the real run applies exactly the plan: no write for unchanged locales', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD });
const calls = ascRoutes([v('v-1', '1.0', 'PREPARE_FOR_SUBMISSION')]);
await capture(() => appleListing(p.ctx(['--version', '1.0.0'])));
expect(methods(calls)).toEqual(['PATCH /v1/appStoreVersions/v-1', 'PATCH /v1/appInfoLocalizations/il-en', 'POST /v1/appInfoLocalizations', 'POST /v1/appStoreVersionLocalizations']);
expect((calls.find(c => c.path === '/v1/appInfoLocalizations/il-en')!.body as any).data.attributes).toEqual({ subtitle: 'Notes that sync' });
expect((calls.find(c => c.path === '/v1/appStoreVersions/v-1')!.body as any).data.attributes).toEqual({ versionString: '1.0.0' });
});
test('a new version is created, then its copied texts are planned again and applied', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD });
const calls = ascRoutes([v('v-0', '1.0.0', 'READY_FOR_DISTRIBUTION')]);
const j = jsonOut((await capture(() => appleListing(p.ctx(['--version', '1.1.0', '--dry', '--json'])))).out) as any;
expect(j.versionPlan).toMatchObject({ action: 'create', reason: 'create version 1.1.0 (no editable iOS version)' });
expect(j.versionLocales).toBeNull();
await capture(() => appleListing(p.ctx(['--version', '1.1.0'])));
expect(methods(calls).filter(m => m.includes('appStoreVersion'))).toEqual(['POST /v1/appStoreVersions', 'POST /v1/appStoreVersionLocalizations']);
});
test('a refused version stops the run; the dry run says so and exits 1', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined }, { 'store/appstore.md': APPLE_MD });
const calls = ascRoutes([v('v-0', '1.2.0', 'READY_FOR_DISTRIBUTION')]);
const r = await capture(() => appleListing(p.ctx(['--version', '1.1.0', '--dry'])));
expect(r.out).toContain('REFUSE: version 1.1.0 is not higher than 1.2.0');
expect(r.exit).toBe(1);
await expect(appleListing(p.ctx(['--version', '1.1.0']))).rejects.toThrow('not higher than 1.2.0');
expect(methods(calls)).toEqual([]);
});
});
describe('play listing plan', () => {
const PLAY_MD = '# Play\n\n## English\n<!-- play: en-US -->\n\n### title\n```\nExample Notes\n```\n\n### short\n```\nNew short\n```\n\n### full\n```\nFull text.\n```\n\n'
+ '## German\n<!-- play: de-DE -->\n<!-- storebound-translation from=en-US status=draft -->\n\n### short\n```\nKurz\n```\n\n### full\n```\nLang.\n```\n';
const routes = () => mockFetch([
{ method: 'POST', match: /\/edits$/, answer: { id: 'edit-1' } },
{ match: /\/edits\/edit-1\/details$/, answer: { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' } },
{ match: /\/edits\/edit-1\/listings$/, answer: { listings: [{ language: 'en-US', title: 'Example Notes', shortDescription: 'Old short', fullDescription: 'Full text.' }] } },
{ method: 'DELETE', match: /\/edits\/edit-1$/, answer: new Response(null, { status: 204 }) },
{ method: 'PUT', match: /\/edits\/edit-1\/listings\/en-US$/, answer: {} },
{ method: 'POST', match: /\/edits\/edit-1:commit$/, answer: {} },
]);
test('--dry --json: old -> new per field, unchanged details, draft language skipped; real run PUTs only the change', async () => {
playEnv();
const p = project({ ...BASE, apple: undefined }, { 'store/listing.md': PLAY_MD });
let calls = routes();
const j = jsonOut((await capture(() => playListing(p.ctx(['--dry', '--json'])))).out) as any;
expect(j.details.action).toBe('same');
expect(j.listings).toEqual([expect.objectContaining({ key: 'en-US', action: 'update', changes: [{ field: 'shortDescription', from: 'Old short', to: 'New short' }] })]);
expect(j.skipped).toEqual([{ key: 'de-DE', reason: expect.stringContaining('draft') }]);
expect(calls.some(c => c.method === 'PUT' || c.path.includes(':commit'))).toBe(false);
calls = routes();
await capture(() => playListing(p.ctx([])));
expect(calls.filter(c => c.method === 'PUT').map(c => c.path.split('/').slice(-2).join('/'))).toEqual(['listings/en-US']);
});
});
describe('apple appinfo plan', () => {
test('--dry --json: categories and age rating old -> new; nothing written', async () => {
ascEnv();
const p = project({ ...BASE, play: undefined, apple: { ...BASE.apple!, info: { categories: { primary: 'PRODUCTIVITY' }, ageRating: { advertising: true }, free: false } } });
const calls = mockFetch([
{ match: /^\/v1\/apps\/6400000101\/appInfos/, answer: page([{ type: 'appInfos', id: 'ai-1', attributes: { state: 'PREPARE_FOR_SUBMISSION' } }]) },
{ match: /^\/v1\/appInfos\/ai-1\?include=/, answer: { data: { id: 'ai-1', relationships: { primaryCategory: { data: { type: 'appCategories', id: 'UTILITIES' } } } } } },
{ match: /^\/v1\/appInfos\/ai-1\/ageRatingDeclaration/, answer: { data: { id: 'ai-1', attributes: { advertising: false, gambling: false, violenceRealistic: 'NONE' } } } },
]);
const j = jsonOut((await capture(() => appInfo(p.ctx(['--dry', '--json'])))).out) as any;
expect(methods(calls)).toEqual([]);
expect(j.items[0]).toMatchObject({ key: 'categories', action: 'update', changes: [{ field: 'primaryCategory', from: 'UTILITIES', to: 'PRODUCTIVITY' }] });
expect(j.items[1].changes).toContainEqual({ field: 'advertising', from: 'false', to: 'true' });
expect(j.items[1].changes.find((c: any) => c.field === 'gambling')).toBeUndefined();
});
});