storebound/cli/validate.ts
CRT a9261a8052 play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune
A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
2026-10-12 08:21:03 +08:00

210 lines
15 KiB
TypeScript

// Store readiness report (read-only on both stores): what still blocks a submission, in the order to fix it.
// storebound validate [--apple] [--play] [--json] [--src store/renders] [--no-local] [--no-health] [--no-scan]
// Without --apple / --play: every store the config has. Exit code 1 when there is at least one blocker.
// Reads only: GETs on App Store Connect, a Play edit that is opened, read and deleted (never committed), and plain GETs
// of the public privacy policy and app-ads.txt URLs. Also checks the local renders folder (if there is one) with the
// `shots check` rules: wrong pixel size, alpha, too few / many images, Play 8 MB and feature graphic format are
// blockers (the upload would fail), text overflow flags from the last render are warnings. --no-local skips it.
// Subscriptions (when the config has them): App Store states, localizations, group names, review screenshots; Play
// subscriptions with an active base plan. App profile: delete-account URL answers (accounts), demo account locally and
// on App Review (sign-in required), Terms of Use URL answers and is linked in the description (subscriptions).
// Project scan (offline, `storebound scan`): rejection risks proven by the project's own files (sign-in without a demo
// account, no account deletion, no Restore Purchases, ATT, purpose strings, privacy manifest, target SDK ...): open
// blockers stay blockers, high and medium risks are warnings, owner-accepted ones (scan.ignore) are left out. --no-scan skips.
// Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { ascClient, type J } from './apple/asc';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCodes, profileOf, sellsOn } from './lib/config';
import { reviewDemoAccount } from './lib/credentials';
import { allSubscriptions, basePlanId } from './lib/subscriptions';
import type { Ctx } from './lib/ctx';
import { checkUrl } from './lib/http';
import { collectHealth } from './health';
import { healthFindings } from './lib/health';
import { log, printJson, warn } from './lib/log';
import { type AppleSnapshot, appleRules, type Finding, formatFindings, type PlaySnapshot, playRequiredImages, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules';
import { rendersCheck } from './shots/check';
import { resolvedTablets } from './shots/report';
import { runScan } from './lib/scan';
import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client';
import { listOneTimeProducts } from './play/products';
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
const { config, root } = loaded;
return {
profile: profileOf(config),
accounts: !!config.accounts?.enabled,
inAppDeletion: !!config.accounts?.inApp,
deletionUrl: config.accounts?.enabled && config.accounts.deletionUrl ? await checkUrl(config.accounts.deletionUrl) : null,
signInRequired: !!config.reviewAccess?.signInRequired,
demoAccountLocal: !!reviewDemoAccount(root),
subscriptions: !!allSubscriptions(config.subscriptions).length,
termsUrl: config.app.termsUrl ? await checkUrl(config.app.termsUrl) : null,
};
}
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
const { config, root } = ctx.loaded;
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
const appId = await c.app();
log('validate', 'apple: reading version, build, localizations');
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
const v: J = (await c.editableVersion()) ?? versions[0] ?? null;
let build: AppleSnapshot['build'] = null;
const locales: AppleSnapshot['locales'] = [];
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
if (v) {
const b = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
if (b) {
const decl = (await c.maybe(`/v1/builds/${b.id}/appEncryptionDeclaration`))?.data;
build = { number: b.attributes.version, processingState: b.attributes.processingState, usesNonExemptEncryption: b.attributes.usesNonExemptEncryption ?? null,
declarationState: decl?.attributes?.appEncryptionDeclarationState ?? null };
}
const info = await c.editableAppInfo();
const infoLocs = new Map((await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`)).map((l: J) => [l.attributes.locale, l.attributes] as const));
for (const l of await c.all(`/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations?limit=50`)) {
const sets = await c.all(`/v1/appStoreVersionLocalizations/${l.id}/appScreenshotSets?limit=50`);
const screenshots: Record<string, number> = {};
for (const s of sets) screenshots[s.attributes.screenshotDisplayType] = (await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`)).length;
const ia = infoLocs.get(l.attributes.locale) ?? {};
locales.push({ locale: l.attributes.locale, name: ia.name, subtitle: ia.subtitle, description: l.attributes.description, keywords: l.attributes.keywords, screenshots });
}
reviewDetail = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes ?? null;
}
log('validate', 'apple: age rating, price, availability, in-app purchases');
const info = await c.editableAppInfo();
const ageRating = (await c.maybe(`/v1/appInfos/${info.id}/ageRatingDeclaration`))?.data?.attributes ?? null;
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
const iaps: AppleSnapshot['iaps'] = [];
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
const shot = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data;
iaps.push({ productId: p.attributes.productId, state: p.attributes.state, reviewScreenshot: !!shot });
}
const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple'));
const subscriptions: NonNullable<AppleSnapshot['subscriptions']> = [];
if (wantSubs.length) {
log('validate', 'apple: subscriptions');
for (const g of await c.all(`/v1/apps/${appId}/subscriptionGroups?limit=200`)) {
const groupLocales = (await c.all(`/v1/subscriptionGroups/${g.id}/subscriptionGroupLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state }));
for (const sub of await c.all(`/v1/subscriptionGroups/${g.id}/subscriptions?limit=200`)) {
const locales = (await c.all(`/v1/subscriptions/${sub.id}/subscriptionLocalizations?limit=200`)).map((l: J) => ({ locale: l.attributes.locale, state: l.attributes.state }));
const shot = !!(await c.maybe(`/v1/subscriptions/${sub.id}/appStoreReviewScreenshot`))?.data;
subscriptions.push({ productId: sub.attributes.productId, group: g.attributes.referenceName, state: sub.attributes.state, locales, groupLocales, reviewScreenshot: shot });
}
}
}
const appleLocale = new Map(config.languages.map(l => [l.id, l.apple] as const));
return {
subscriptions, expectedSubscriptions: wantSubs.map(({ group, sub }) => ({ id: sub.id, group: group.name,
locales: Object.keys(sub.listings).map(id => appleLocale.get(id)).filter((x): x is string => !!x) })),
profile: await collectProfile(ctx.loaded),
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState } : null,
build, locales, reviewDetail, ageRating,
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
requiredDisplayTypes: requiredDisplayTypes(config.apple),
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
availability: !!avail?.data,
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
};
}
export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName);
const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
log('validate', `play: read-only edit ${e.id} (deleted afterwards)`);
try {
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${e.id}/details`);
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? [];
const listings: PlaySnapshot['listings'] = [];
for (const l of raw) {
const images: Record<string, number> = {};
for (const type of Object.keys(playRequiredImages(tablets)))
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length;
listings.push({ ...l, images });
}
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [])
.map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) }));
const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') }));
const website = details.contactWebsite ?? null;
const appAdsLines = config.ads?.appAdsTxt ?? [];
const adsLikely = !!config.ads || Object.values(config.dataSafety?.data ?? {}).some(u => u.collect.includes('advertising'));
let appAds = null;
if (website && (appAdsLines.length || adsLikely)) {
try { appAds = await checkUrl(new URL('/app-ads.txt', website).href, { body: true }); } catch { appAds = { url: website, status: null, error: 'bad website URL' }; }
}
const wantSubs = allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'play'));
const subscriptions: NonNullable<PlaySnapshot['subscriptions']> = [];
if (wantSubs.length) {
let page = '';
do {
const r = await play.call<{ subscriptions?: { productId: string; basePlans?: { basePlanId: string; state?: string }[] }[]; nextPageToken?: string }>('GET', `${play.API}/subscriptions?pageSize=100${page ? '&pageToken=' + page : ''}`);
for (const x of r.subscriptions ?? []) subscriptions.push({ productId: x.productId, basePlans: (x.basePlans ?? []).map(b => ({ basePlanId: b.basePlanId, state: b.state })) });
page = r.nextPageToken ?? '';
} while (page);
}
return {
tablets, subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })),
profile: await collectProfile(ctx.loaded),
defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US',
expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products,
expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely,
};
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
}
/** Scan findings as validate findings: blocker stays blocker, high / medium become warnings; low and owner-accepted
* ones are left out (they are in the full `storebound scan` report). */
export function scanAsFindings(f: ScanFinding[], stores: { apple: boolean; play: boolean }): Finding[] {
return f.filter(x => !x.accepted && x.severity !== 'low' && stores[x.store]).map(x => ({
store: x.store, level: x.severity === 'blocker' ? 'blocker' as const : 'warn' as const, code: `scan-${x.rule}`,
msg: `${x.guideline}: ${x.title}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}`,
fix: `${x.fix} · ${x.skill} skill`,
}));
}
export async function run(ctx: Ctx): Promise<void> {
const { config } = ctx.loaded;
const a = ctx.args;
const both = !a.has('apple') && !a.has('play');
const findings: Finding[] = [];
const dir = ctx.loaded.resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
const local = a.has('no-local') ? null : rendersCheck(config, dir);
if (local) log('validate', `local renders: ${dir}`);
if ((both || a.has('apple')) && config.apple) {
try { findings.push(...appleRules({ ...(await collectApple(ctx)), renders: local?.apple })); }
catch (e) { findings.push({ store: 'apple', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('apple', local?.apple)); }
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
if ((both || a.has('play')) && config.play) {
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); }
} else if (a.has('play')) warn('validate', 'no play section in the config');
if (!a.has('no-scan')) {
log('validate', 'project scan (offline): rejection risks in the project files');
try {
const { report } = await runScan(ctx.loaded.root);
findings.push(...sortFindings(scanAsFindings(report.findings, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play })));
} catch (e) { warn('validate', `project scan failed: ${(e as Error).message}`); }
}
if (!a.has('no-health') && (config.apple || config.play)) {
log('validate', 'vitals and diagnostics (last 7 days)');
const h = await collectHealth(ctx, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play, days: 7 });
findings.push(...sortFindings(healthFindings(h, { unreadable: 'ok' })));
}
const blockers = findings.filter(f => f.level === 'blocker').length;
if (a.has('json')) printJson({ blockers, findings });
else {
console.log(formatFindings(findings));
log('validate', blockers ? `${blockers} blocker(s)` : 'no blockers (UI-only steps are not visible to the API: App Privacy, Play App content)');
}
if (blockers) process.exitCode = 1;
}