323 lines
24 KiB
TypeScript
323 lines
24 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 { templateBlockers } from './lib/template-answers';
|
|
import { gameServicesFindings, gameServicesStatus } from './games-status';
|
|
import { ascClient, EDITABLE as APPLE_EDITABLE, type J } from './apple/asc';
|
|
import { allVersions, type AscPlatform, parsePlatform, platformOf, stateOf, versionsPath } from './apple/platform';
|
|
import { capabilityReport } from './apple/signing';
|
|
import { firstProductReport } from './apple/first-products';
|
|
import { requiredDisplayTypes } from './apple/screenshots';
|
|
import { pendingScreenshots, setRows, type ShotRow, updateLedger } from './apple/screenshot-health';
|
|
import { type Loaded, playCategoryOf, 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 { EDIT_GONE, playClient } from './play/client';
|
|
import { listOneTimeProducts } from './play/products';
|
|
import { creativeNote } from './shots/creative';
|
|
import { creativeWaiting, localCreative } from './apple/creative-assets';
|
|
import { liveDeviceFamilies } from './apple/device-families';
|
|
import { type FamilyVerdict, familyVerdicts, projectFamilies } from './lib/device-families';
|
|
import { fixFor, GUIDELINE, hitLine } from './lib/cross-store';
|
|
import { listingFileMentions } from './lib/listing-mentions';
|
|
import { brokerConfig, brokerPlayInfo, enabledApisOf, grantsOf, type PlayNeed, playGaps, sdkConfigGaps } from './lib/broker';
|
|
import { playCredentials } from './lib/google-auth';
|
|
import { usesPlayGames } from './lib/game-services';
|
|
|
|
/** What the config's Play work needs from the service account: uploads and the listing always, products,
|
|
* declarations and Play Games when the config has them. */
|
|
export function playNeedsOf(config: Loaded['config']): PlayNeed[] {
|
|
const needs: PlayNeed[] = ['upload', 'listing'];
|
|
if ((config.products ?? []).some(p => sellsOn(config, p, 'play')) || allSubscriptions(config.subscriptions).some(x => sellsOn(config, x.sub, 'play'))) needs.push('iap');
|
|
if (config.dataSafety) needs.push('declarations');
|
|
if (usesPlayGames(config)) needs.push('games');
|
|
return needs;
|
|
}
|
|
|
|
/** With a credential broker: the Play permissions and enabled APIs it lists for the package, the gaps for what the
|
|
* config needs (blockers: the commands would get a 403), and SDK config drift. Nothing without a broker. */
|
|
export async function brokerFindings(loaded: Loaded): Promise<Finding[]> {
|
|
const { config, root } = loaded;
|
|
const pkg = config.play?.packageName;
|
|
if (!pkg) return [];
|
|
let cfg;
|
|
try { cfg = brokerConfig(root); } catch (e) { return [{ store: 'play', level: 'warn', code: 'broker-config', msg: (e as Error).message, fix: '~/.storebound/broker.json: { "url", "apiKeyFile" }' }]; }
|
|
if (!cfg) return [];
|
|
const r = await brokerPlayInfo(cfg, pkg);
|
|
if (r.kind === 'not-registered') return [{ store: 'play', level: 'warn', code: 'broker-not-registered', msg: r.message, fix: 'register the package on the credential broker (google_play_set)' }];
|
|
if (r.kind !== 'ok') return [{ store: 'play', level: 'warn', code: 'broker-unavailable', msg: `${r.message}: permission checks skipped`, fix: 'check the credential broker url and API key (~/.storebound/broker.json)' }];
|
|
const out: Finding[] = [];
|
|
const grants = grantsOf(r.info), apis = enabledApisOf(r.info);
|
|
out.push({ store: 'play', level: 'ok', code: 'broker-access', msg: `credential broker: ${r.info.account ?? 'service account'} on ${pkg}: Play permissions ${grants ? (grants.join(', ') || 'none') : 'not listed'}; enabled APIs ${apis ? (apis.join(', ') || 'none') : 'not listed'}` });
|
|
for (const g of playGaps(r.info, playNeedsOf(config))) out.push({ store: 'play', level: 'blocker', code: g.kind === 'grant' ? 'broker-grant' : 'broker-api', msg: g.message, fix: g.fix, url: g.url });
|
|
for (const m of sdkConfigGaps(r.info, root)) out.push({ store: 'play', level: 'warn', code: 'sdk-config', msg: m });
|
|
return out;
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|
|
|
|
/** Header and Search Results files uploaded but not approved yet (the slots take only approved assets). Never fails
|
|
* validate: a local file problem or an unreadable library is a note for creative-assets itself. */
|
|
async function creativeWaitingOf(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<AppleSnapshot['creativeWaiting']> {
|
|
try {
|
|
const { local } = localCreative(ctx);
|
|
return local.size ? await creativeWaiting(c, appId, local) : [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
/** Live version's device families against the project and apple.ipad (cli/lib/device-families.ts). A failed read is a warning, never a blocker. */
|
|
async function deviceFamilyVerdicts(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<FamilyVerdict[]> {
|
|
const { config, root } = ctx.loaded;
|
|
try {
|
|
return familyVerdicts({ live: await liveDeviceFamilies(c, appId), project: projectFamilies(root, config.apple!.bundleId, config.apple?.build?.project), ipad: config.apple?.ipad });
|
|
} catch (e) { return [{ level: 'warn', msg: `device families not compared: ${(e as Error).message.slice(0, 200)}` }]; }
|
|
}
|
|
|
|
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');
|
|
// the platform: --platform, else the one with an editable version (several: iOS, and the others are named)
|
|
const every = await allVersions(c, appId);
|
|
const editableOf = (x: J) => APPLE_EDITABLE.has(stateOf(x));
|
|
const withEditable = [...new Set(every.filter(editableOf).map(platformOf))];
|
|
const platform: AscPlatform = parsePlatform(ctx.args.get('platform')) ?? (withEditable.length === 1 ? withEditable[0]! : withEditable.includes('IOS') || !every.length || every.some(x => platformOf(x) === 'IOS') ? 'IOS' : platformOf(every[0]));
|
|
const versions = await c.all(versionsPath(appId, platform, 10));
|
|
const v: J = (await c.editableVersion(platform)) ?? versions[0] ?? null;
|
|
const otherPlatformVersions = every.filter(x => platformOf(x) !== platform && editableOf(x)).map(x => ({ versionString: String(x.attributes.versionString), state: stateOf(x), platform: platformOf(x) }));
|
|
let build: AppleSnapshot['build'] = null;
|
|
const locales: AppleSnapshot['locales'] = [];
|
|
let reviewDetail: AppleSnapshot['reviewDetail'] = null;
|
|
const shotRows: ShotRow[] = [];
|
|
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) {
|
|
const shots = await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`);
|
|
screenshots[s.attributes.screenshotDisplayType] = shots.length;
|
|
shotRows.push(...setRows(shots, l.attributes.locale, s.attributes.screenshotDisplayType));
|
|
}
|
|
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'] = [];
|
|
const iapList = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
|
const configIds = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
|
|
const firstProductGaps = configIds.length ? await firstProductReport(c, appId, { iaps: iapList, only: configIds, versionState: v ? (v.attributes.appVersionState ?? v.attributes.appStoreState) : undefined }) : [];
|
|
for (const p of iapList) {
|
|
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, copyright: v.attributes.copyright ?? null, platform } : null,
|
|
otherPlatformVersions,
|
|
creativeWaiting: await creativeWaitingOf(ctx, c, appId),
|
|
screenshotsPending: pendingScreenshots(shotRows, updateLedger(root, shotRows), Date.now()),
|
|
configCopyright: config.app.copyright,
|
|
deviceFamilies: platform === 'IOS' ? await deviceFamilyVerdicts(ctx, c, appId) : undefined,
|
|
build, locales, reviewDetail, ageRating,
|
|
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
|
requiredDisplayTypes: requiredDisplayTypes(config.apple, platform),
|
|
iphoneDuo: !!config.apple?.iphoneDuo, play: !!config.play,
|
|
macVersion: every.some(x => platformOf(x) === 'MAC_OS'), mac: !!config.apple?.mac,
|
|
priceSchedule: !!sched?.included?.some((x: J) => x.type === 'appPrices'),
|
|
availability: !!avail?.data,
|
|
firstProductGaps,
|
|
iaps, expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id),
|
|
privacyUrl: await checkUrl(config.app.privacyPolicyUrl),
|
|
capabilityMismatches: await capabilityReport(c, config, root).then(r => r.mismatches.map(m => ({ msg: m.msg, fix: m.fix })), e => [{ msg: `capabilities not compared: ${(e as Error).message.slice(0, 200)}`, fix: 'storebound apple signing status' }]),
|
|
};
|
|
}
|
|
|
|
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'));
|
|
// play.read: one edit under the machine lock, deleted only after every read inside has finished, and opened anew once
|
|
// when Play invalidates it meanwhile ("This Edit has been deleted"). A bare POST /edits here was neither locked nor
|
|
// retried, so another storebound process (or the Console) ending the edit failed the screenshot reads with a false blocker.
|
|
return play.read(async editId => {
|
|
log('validate', `play: read-only edit ${editId} (deleted afterwards)`);
|
|
const details = await play.call<{ defaultLanguage?: string; contactWebsite?: string }>('GET', `${play.API}/edits/${editId}/details`);
|
|
const raw = (await play.call<{ listings?: { language: string; title?: string; shortDescription?: string; fullDescription?: string }[] }>('GET', `${play.API}/edits/${editId}/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/${editId}/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/${editId}/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 ?? '') }));
|
|
// the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`)
|
|
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${editId}/countryAvailability/production`)
|
|
.then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null);
|
|
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, category: playCategoryOf(config), productionCountries,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** 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`, ...(x.action ? { action: x.action } : {}),
|
|
}));
|
|
}
|
|
|
|
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)); }
|
|
const creative = creativeNote(config, dir);
|
|
if (creative) findings.push({ store: 'apple', level: 'ok', code: 'creative-assets', msg: creative, fix: 'storebound apple creative-assets --dry' });
|
|
} else if (a.has('apple')) warn('validate', 'no apple section in the config');
|
|
if ((both || a.has('play')) && config.play) {
|
|
// the broker's grants first: a missing permission is named before the edit reads hit the same 403
|
|
const access = await brokerFindings(ctx.loaded).catch(e => [{ store: 'play' as const, level: 'warn' as const, code: 'broker-unavailable', msg: (e as Error).message }]);
|
|
findings.push(...access);
|
|
try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); }
|
|
catch (e) {
|
|
// a Play edit that vanished under the reads is a tool problem (the retry inside play.read failed too), never a store blocker
|
|
const gone = EDIT_GONE.test((e as Error).message);
|
|
findings.push(gone
|
|
? { store: 'play', level: 'warn', code: 'edit-gone', msg: `Play validate could not finish its read-only edit (${(e as Error).message.slice(0, 300)}): a tool problem, not a store finding`, fix: 'storebound validate --play (run it again; close other storebound runs and Console saves first)' }
|
|
: { store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 600) }, ...rendersFindings('play', local?.play));
|
|
}
|
|
} else if (a.has('play')) warn('validate', 'no play section in the config');
|
|
// Game Center / Play Games Services: entitlement, APP_ID resource, store state vs the config
|
|
if (config.games || config.apple) {
|
|
try { findings.push(...gameServicesFindings(await gameServicesStatus(ctx.loaded, { apple: both || a.has('apple'), play: both || a.has('play') }))); }
|
|
catch (e) { warn('validate', `game services: ${(e as Error).message}`); }
|
|
}
|
|
// other platforms named in a store's listing text (App Store 2.3.10, Play Metadata): blockers (pitfall 77)
|
|
try {
|
|
for (const h of listingFileMentions(ctx.loaded, { apple: (both || a.has('apple')) && !!config.apple, play: (both || a.has('play')) && !!config.play }))
|
|
findings.push({ store: h.store, level: 'blocker', code: 'other-platforms', msg: `${GUIDELINE[h.store]}: ${hitLine(h)}`, fix: fixFor(h.store, h.field) });
|
|
} catch (e) { warn('validate', `platform mentions: ${(e as Error).message}`); }
|
|
// template compliance answers: the scan reports them; without the scan, here
|
|
if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg });
|
|
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;
|
|
// where Play tokens come from (MCP validate reads it): "broker" or "file", never a value
|
|
const credentials = config.play ? { play: { source: playCredentials(ctx.loaded.root).source } } : {};
|
|
if (a.has('json')) printJson({ blockers, findings, credentials });
|
|
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;
|
|
}
|