296 lines
25 KiB
TypeScript
296 lines
25 KiB
TypeScript
// Offline sanity check of everything the store commands read: config, both listing files, the screenshot copy, data
|
|
// safety answers, product texts vs store limits, the app profile (accounts + deletion, demo account for review,
|
|
// subscriptions + terms URL) and the files the config points at. No network; credentials are only checked for presence.
|
|
// storebound check everything above
|
|
// storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors
|
|
// --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems
|
|
import { localGameFindings } from './games-status';
|
|
import { marksNotes, marksPath, parseMarks } from './lib/shots-copy-translate';
|
|
import { templateBlockers } from './lib/template-answers';
|
|
import { COPYRIGHT_MISSING, templatePlaceholders } from './lib/validate-rules';
|
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy';
|
|
import { privacyTypes } from './lib/console-answers';
|
|
import { type BundleManifest, bundleTrackingIssues, ipaManifests, projectSdkManifests, sdkOfProjectPath } from './lib/privacy-files';
|
|
import { trackingIssue } from './lib/privacy-manifest';
|
|
import { type AsoFinding, auditAso } from './lib/aso';
|
|
import { draftLocales } from './lib/aso-intel';
|
|
import { appleName, playCodes } from './lib/config';
|
|
import { fieldsMentions } from './lib/cross-store';
|
|
import { appBuildFile, pickGradle, versionSourceIssues, versionSourceMessage } from './lib/gradle';
|
|
import { playConsoleReminders } from './lib/console-plan';
|
|
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
|
|
import { credentialFileIssues, credentialStatus, reviewDemoAccount } from './lib/credentials';
|
|
import { log, printJson, warn } from './lib/log';
|
|
import { profileIssues } from './lib/profile';
|
|
import { detectProject, grepOwn, where } from './lib/scan/project';
|
|
import { remoteFeatureFindings, SIGNALS } from './lib/scan/rules';
|
|
import { reviewShotProblem } from './apple/iap-review';
|
|
import { allSubscriptions } from './lib/subscriptions';
|
|
import { type CopyFile, validateCopy } from './shots/lib';
|
|
import { posix, readPulled } from './lib/art-pull';
|
|
import { creativeNote } from './shots/creative';
|
|
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
|
|
import { iphoneDuoNote } from './shots/spec';
|
|
import { hitLine } from './lib/cross-store';
|
|
import { findFastlaneTraces, traceMessage } from './lib/fastlane-traces';
|
|
import { listingFileMentions } from './lib/listing-mentions';
|
|
import { projectFamilies } from './lib/device-families';
|
|
|
|
/** The newest .ipa in a folder (apple build's export), or null. */
|
|
function newestIpa(dir: string): string | null {
|
|
try { return readdirSync(dir).filter(f => f.endsWith('.ipa')).map(f => join(dir, f)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0] ?? null; } catch { return null; }
|
|
}
|
|
|
|
function aso(ctx: Ctx): void {
|
|
const { config, resolve } = ctx.loaded;
|
|
const appleMd = config.apple ? readFileSync(resolve(config.apple.listing ?? 'store/appstore.md'), 'utf8') : '';
|
|
const apple = config.apple ? parseAppStoreListing(appleMd) : undefined;
|
|
const play = config.play ? parsePlayListing(readFileSync(resolve(config.play.listing ?? 'store/listing.md'), 'utf8'), config.app.name) : undefined;
|
|
const f: AsoFinding[] = auditAso({ apple, play, appName: appleName(config), storefront: ctx.args.get('storefront') ?? 'US' });
|
|
for (const l of draftLocales(appleMd)) f.push({ level: 'warn', where: `apple ${l}`, msg: 'keywords: draft from `aso suggest`, not reviewed yet (review, then delete the storebound-aso draft line)' });
|
|
if (ctx.args.has('json')) { const errors = f.filter(x => x.level === 'error').length; printJson({ findings: f, errors }); if (errors) process.exitCode = 1; return; }
|
|
const tag = { error: 'ERROR', warn: 'warn ', info: 'info ' } as const;
|
|
for (const x of f) console.log(`${tag[x.level]} [${x.where}] ${x.msg}`);
|
|
const errors = f.filter(x => x.level === 'error').length;
|
|
log('aso', `${errors} errors, ${f.filter(x => x.level === 'warn').length} warnings, ${f.filter(x => x.level === 'info').length} hints`);
|
|
if (errors) process.exitCode = 1;
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
if (ctx.args.has('aso')) return aso(ctx);
|
|
const { config, resolve, path } = ctx.loaded;
|
|
log('check', `config ok: ${path}`);
|
|
const problems: string[] = [];
|
|
const items: { what: string; detail: string }[] = [];
|
|
const warnings: string[] = [];
|
|
const hints: string[] = [];
|
|
const note = (msg: string): void => { warnings.push(msg); warn('check', msg); };
|
|
const tryDo = (what: string, fn: () => string): void => {
|
|
try { const detail = fn(); items.push({ what, detail }); log('check', `${what}: ${detail}`); } catch (e) {
|
|
const err = e as NodeJS.ErrnoException;
|
|
// a missing file is the common first-run case: name it and say how to fix it, not the raw ENOENT
|
|
const file = err.code === 'ENOENT' && typeof err.path === 'string' ? relative(process.cwd(), err.path) || err.path : '';
|
|
problems.push(file ? `${what}: ${file} is missing · storebound init creates it` : `${what}: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
if (config.play) tryDo('play listing', () => {
|
|
const f = resolve(config.play!.listing ?? 'store/listing.md');
|
|
const copy = parsePlayListing(readFileSync(f, 'utf8'), config.app.name);
|
|
const want = config.languages.flatMap(playCodes);
|
|
const missing = want.filter(c => !copy.some(x => x.lang === c));
|
|
if (missing.length) note(`play listing has no section for ${missing.join(', ')}`);
|
|
const def = copy.find(x => x.lang === (config.play!.defaultLanguage ?? 'en-US'));
|
|
const same = copy.filter(x => def && x !== def && x.whatsNew && x.whatsNew === def.whatsNew && x.lang.slice(0, 2) !== def.lang.slice(0, 2));
|
|
if (same.length) note(`what's new of ${same.map(x => x.lang).join(', ')} is the same text as ${def!.lang}: translate it`);
|
|
// release notes naming iOS / the App Store are refused at release time (pitfall 77): say so now (a notes block the
|
|
// file scan did not reach, e.g. one outside a fenced block, still counts)
|
|
const seen = new Set(listingFileMentions(ctx.loaded, { apple: false, play: true }).filter(h => h.field === 'whatsNew').map(h => h.locale));
|
|
for (const c of copy) if (c.whatsNew && !seen.has(c.lang)) for (const h of fieldsMentions('play', c.lang, { whatsNew: c.whatsNew }, config.listing?.allowPlatformMentions)) problems.push(`Google Play release notes: ${hitLine(h)}`);
|
|
return `${copy.length} languages`;
|
|
});
|
|
if (config.apple) tryDo('appstore listing', () => {
|
|
const f = resolve(config.apple!.listing ?? 'store/appstore.md');
|
|
const copy = parseAppStoreListing(readFileSync(f, 'utf8'));
|
|
const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple);
|
|
if (missing.length) note(`appstore listing has no section for ${missing.join(', ')}`);
|
|
for (const i of appleNameIssues(copy, config.languages.flatMap(l => l.apple ? [l.apple] : []), appleName(config), config.app.name)) {
|
|
if (i.level === 'error') problems.push(i.msg); else { hints.push(i.msg); log('check', `hint: ${i.msg}`); }
|
|
}
|
|
return `${Object.keys(copy).length} locales`;
|
|
});
|
|
// fastlane uploads left next to storebound (pitfall 96)
|
|
try { const tr = findFastlaneTraces(dirname(path)); if (tr.length) for (const t of tr) note(traceMessage(t)); } catch (e) { warn('check', `fastlane traces: ${(e as Error).message}`); }
|
|
// other platforms named in a store's text: App Store 2.3.10 / Play Metadata, a blocker (pitfall 77)
|
|
try { for (const h of listingFileMentions(ctx.loaded)) problems.push(`other platform in ${hitLine(h)}`); } catch (e) { warn('check', `platform mentions: ${(e as Error).message}`); }
|
|
// listing translation drafts (storebound listing translate): fine to keep working on, not to push unreviewed
|
|
for (const f of [config.apple ? resolve(config.apple.listing ?? 'store/appstore.md') : '', config.play ? resolve(config.play.listing ?? 'store/listing.md') : ''].filter(Boolean)) {
|
|
if (!existsSync(f)) continue;
|
|
const marks = [...readFileSync(f, 'utf8').matchAll(/^## (\S+)[^\n]*\n(?:<!--\s*play:[^\n]*\n)?<!--\s*storebound-translation\s+from=\S+\s+status=(translate|draft)/gm)];
|
|
const todo = marks.filter(m => m[2] === 'translate').map(m => m[1]!), drafts = marks.filter(m => m[2] === 'draft').map(m => m[1]!);
|
|
if (todo.length) note(`${relative(process.cwd(), f) || f}: not translated yet (status=translate): ${todo.join(', ')}`);
|
|
if (drafts.length) note(`${relative(process.cwd(), f) || f}: machine-translated drafts not reviewed: ${drafts.join(', ')} (storebound listing check, then listing reviewed --lang <ids>)`);
|
|
}
|
|
// Privacy manifests (ITMS-91064: NSPrivacyTracking and NSPrivacyTrackingDomains agree in every manifest, the app's,
|
|
// the SDKs' and the last built IPA's) and the App Privacy answers against the iOS project: ATT string, a manifest
|
|
// that declares tracking (the app's or an SDK's), PrivacyInfo.xcprivacy types
|
|
const apTypes = config.appPrivacy?.types;
|
|
const hasTypes = !!apTypes && Object.keys(apTypes).length > 0;
|
|
const facts = config.apple ? detectProject(ctx.loaded.root) : null;
|
|
// appPrivacy.types is authoritative (an unlisted type is answered "not collected"): name dataSafety types it leaves out
|
|
if (hasTypes && config.apple && config.dataSafety?.collectsData) {
|
|
const left = [...privacyTypes(config)].filter(([name]) => { const id = typeIdOf(name); return id && id !== 'OTHER_DATA_TYPES' && !apTypes![id]; });
|
|
if (left.length) note(`appPrivacy.types does not list ${left.map(([name, g]) => `${typeIdOf(name)} (dataSafety ${g.from.join(', ')})`).join(', ')}: App Privacy answers ${left.length === 1 ? 'it' : 'them'} "not collected". Add ${left.length === 1 ? 'it' : 'them'} to appPrivacy.types if the app or an SDK collects ${left.length === 1 ? 'it' : 'them'}`);
|
|
}
|
|
if (hasTypes && facts && !facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
|
|
if (facts?.ios) {
|
|
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
|
|
const ownPaths = new Set(facts.privacyManifests.map(m => m.path));
|
|
const sdkMs = projectSdkManifests(ctx.loaded.root).filter(s => !ownPaths.has(s.manifest.path));
|
|
for (const m of facts.privacyManifests) { const i = trackingIssue(m, sdkOfProjectPath(m.path)); if (i) problems.push(i); }
|
|
for (const s of sdkMs) { const i = trackingIssue(s.manifest, s.sdk); if (i) problems.push(i); }
|
|
const ipa = newestIpa(resolve(join(config.apple!.build?.out ?? 'build/ios', 'export')));
|
|
let built: BundleManifest[] = [];
|
|
if (ipa) {
|
|
try { built = ipaManifests(ipa); } catch (e) { note(`${relative(process.cwd(), ipa)}: privacy manifests not readable (${(e as Error).message})`); }
|
|
for (const i of bundleTrackingIssues(built)) problems.push(`${relative(process.cwd(), ipa)}: ${i} Rebuild after the fix (storebound apple build).`);
|
|
}
|
|
const tracking: TrackingSources = {
|
|
sdkManifests: [...sdkMs.filter(s => s.manifest.tracking === true).map(s => s.sdk), ...built.filter(b => b.sdk && b.manifest.tracking === true).map(b => b.sdk!)],
|
|
trackerSdks: facts.sdks.filter(s => s.sdk.tracking).map(s => s.sdk.name),
|
|
};
|
|
const say = (f: PrivacyMismatch) => { if (f.level === 'error') problems.push(f.msg); else if (f.level === 'warn') note(f.msg); else { hints.push(f.msg); log('check', `hint: ${f.msg}`); } };
|
|
if (hasTypes) {
|
|
const found = appPrivacyMismatches(apTypes!, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own, tracking });
|
|
found.forEach(say);
|
|
const n = found.filter(f => f.level !== 'info').length;
|
|
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project` });
|
|
log('check', `app privacy: ${Object.keys(apTypes!).length} types answered, ${n} mismatch(es) with the iOS project`);
|
|
}
|
|
const typesTrack = hasTypes && Object.values(apTypes!).some(a => a?.tracking === true);
|
|
if (config.appPrivacy?.tracking === true && !typesTrack && own) trackingDeclaredIssues('appPrivacy.tracking is true', own, tracking).forEach(say);
|
|
}
|
|
// template placeholders in the credentials ("YOUR_KEY_ID", "/absolute/path/to/...", "<...>"): missing, never a credential
|
|
const ph = credentialStatus(ctx.loaded.root).filter(c => c.status === 'placeholder').map(c => c.key);
|
|
if (ph.length) note(`credentials: ${ph.join(', ')} still hold template placeholders (env or .storebound/credentials.json): treated as missing; put the real value in or leave it empty`);
|
|
// credential files that are not on this machine, an upload keystore the stored password / alias do not open
|
|
try { for (const i of credentialFileIssues(ctx.loaded.root)) problems.push(`credentials: ${i}`); } catch (e) { warn('check', `credential files: ${(e as Error).message}`); }
|
|
// scan.ignore entries that accept no finding (a typo, a title that changed, a fixed issue) are never silent
|
|
if (config.scan?.ignore?.length) {
|
|
const { runScan } = await import('./lib/scan');
|
|
try { for (const n of (await runScan(ctx.loaded.root, { config: ctx.loaded.path })).report.notes) if (n.startsWith('scan.ignore[')) note(n); } catch (e) { note(`scan.ignore not checked: ${(e as Error).message}`); }
|
|
}
|
|
if (config.apple && !config.app.copyright?.trim()) problems.push(COPYRIGHT_MISSING);
|
|
// the App Review demo account must work on the live backend: a capture sign-in against a local stack that uses it
|
|
// means local-only credentials are (or will be) stored where review-details sends them to App Review
|
|
{ const cap = (config as { shots?: { capture?: { baseUrl?: string; auth?: { request?: { url?: string } } } } }).shots?.capture;
|
|
const authText = JSON.stringify(cap?.auth ?? {});
|
|
const local = /\b(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|[a-z0-9-]+\.(localhost|test|local))\b/i;
|
|
if (/cred:reviewDemoAccount/.test(authText) && (local.test(cap?.baseUrl ?? '') || local.test(cap?.auth?.request?.url ?? '')))
|
|
note('shots.capture.auth signs in to a local stack with reviewDemoAccount, the account App Review gets from apple review-details: put the local demo sign-in in captureAuth in .storebound/credentials.json and use ${cred:<name>}'); }
|
|
const duo = iphoneDuoNote({ apple: !!config.apple, duo: !!config.apple?.iphoneDuo, play: !!config.play });
|
|
if (duo?.level === 'info') { hints.push(duo.msg); log('check', `hint: ${duo.msg}`); } else if (duo) note(duo.msg);
|
|
// device families: offline only the project and the config can be compared; the live version's families need the store
|
|
if (config.apple) {
|
|
const proj = projectFamilies(ctx.loaded.root, config.apple.bundleId, config.apple.build?.project);
|
|
const iosOnly = !proj || !proj.families.ipad;
|
|
if (proj && proj.families.ipad && config.apple.ipad !== true) note(`${proj.file} builds for iPad (TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')}) but apple.ipad is not true: iPad screenshots are not required or checked (set apple.ipad: true)`);
|
|
else if (proj && !proj.families.ipad && config.apple.ipad === true) note(`apple.ipad is true but ${proj.file} builds TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')} (no iPad): set "1,2" or apple.ipad: false`);
|
|
else if (iosOnly && config.apple.ipad !== true) note(`device families: ${proj ? `iPhone only (apple.ipad not true, TARGETED_DEVICE_FAMILY ${proj.configurations.join(' / ')})` : 'no Xcode project found here'}, and the families of the live version are not known offline: if it supports iPad, Apple refuses an iPhone-only upload after the build (ITMS-90101). storebound validate and apple build compare with the store first`);
|
|
}
|
|
const creative = creativeNote(config, ctx.loaded.resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
|
|
if (creative) { hints.push(creative); log('check', `hint: ${creative}`); }
|
|
// the scaffolder's own placeholders would reach the stores as the app's copyright, contact and legal pages
|
|
const tph = templatePlaceholders(config as never);
|
|
if (tph.length) problems.push(`template placeholders still in the config: ${tph.join(', ')} ("Example Studio", example.com): the stores would show them as your copyright, contact address and legal pages; put the real values in`);
|
|
if (config.dataSafety) tryDo('data safety', () => {
|
|
const rows = dataSafetyRows(config.dataSafety!, config.accounts);
|
|
// conditional required rows (e.g. the delete-account URL once accounts exist): Play answers 400 without them
|
|
// and pairs not in Play's current form (cli/data/play-data-safety-form.csv): Play answers 400 for those too
|
|
for (const i of dataSafetyIssues(rows)) problems.push(`data safety: ${i.msg}`);
|
|
return `${rows.length - 1} CSV rows`;
|
|
});
|
|
for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) {
|
|
if (config.apple && len(l.description) > APPLE_IAP_LIMITS.description) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > ${APPLE_IAP_LIMITS.description} (App Store limit)`);
|
|
if (len(l.description) > 200) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 200 (Play limit)`);
|
|
}
|
|
// App Review screenshots of in-app purchases: Apple fails any non-screenshot size after upload (IMAGE_INCORRECT_DIMENSIONS)
|
|
if (config.apple) for (const f of new Set((config.products ?? []).map(p => p.reviewScreenshot).filter((x): x is string => !!x))) {
|
|
const file = resolve(f);
|
|
if (!existsSync(file)) continue; // reported with the other missing files below
|
|
const bad = reviewShotProblem(readFileSync(file));
|
|
if (bad) problems.push(`products[].reviewScreenshot ${f}: ${bad}`);
|
|
}
|
|
// store art pulled from the live stores (art pull): the render sources (screenshot captions, icon artwork, logo)
|
|
// are needed only to render again, so their gaps are notes, not problems
|
|
const pulled = readPulled(resolve(config.renders ?? config.shots?.out ?? 'store/renders'));
|
|
const fromStores = !!pulled && Object.keys(pulled.files).length > 0;
|
|
if (fromStores) log('check', `store art: ${Object.keys(pulled!.files).length} file(s) pulled from the stores (${posix(relative(ctx.loaded.root, resolve(config.renders ?? config.shots?.out ?? 'store/renders')))}/pulled.json); render sources are optional`);
|
|
if (config.shots && fromStores) {
|
|
try {
|
|
const copy = JSON.parse(readFileSync(resolve(config.shots.copy), 'utf8')) as CopyFile;
|
|
const e = validateCopy(copy, config.shots, config.languages.map(l => l.id));
|
|
if (e.length) note(`shots copy (only needed to render again; the store art is pulled): ${e.join('; ')}`);
|
|
} catch (e) { note(`shots copy (only needed to render again; the store art is pulled): ${(e as Error).message}`); }
|
|
} else if (config.shots) tryDo('shots copy', () => {
|
|
const copy = JSON.parse(readFileSync(resolve(config.shots!.copy), 'utf8')) as CopyFile;
|
|
const e = validateCopy(copy, config.shots!, config.languages.map(l => l.id));
|
|
if (e.length) throw new Error(e.join('; '));
|
|
return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`;
|
|
});
|
|
if (config.shots) {
|
|
try {
|
|
const cp = resolve(config.shots.copy);
|
|
const mf = marksPath(cp);
|
|
if (existsSync(mf)) for (const n of marksNotes(parseMarks(readFileSync(mf, 'utf8')), relative(process.cwd(), cp) || cp)) note(n);
|
|
} catch { /* unreadable marks: shots copy check says so */ }
|
|
}
|
|
// Capacitor: the native builds ship the web assets cap sync copied last (pitfall 20)
|
|
for (const store of ['apple', 'play'] as const) {
|
|
if (!config[store] || prebuildFor(config, store)) continue;
|
|
const b = store === 'play' ? config.play?.build?.dir : config.apple?.build?.workspace ?? config.apple?.build?.project;
|
|
let cap: CapProject | null = null;
|
|
try { cap = capacitorProject(ctx.loaded.root, b && store === 'apple' ? dirname(b) : b); } catch (e) { note(`${store}: ${(e as Error).message}`); continue; }
|
|
if (!cap) continue;
|
|
const st = webState(ctx.loaded.root, store, cap);
|
|
if (st.staleness.stale) note(`${store}: ${staleMessage(st.path, st.staleness, st.copied)}. Set build.prebuild (e.g. "npm run build && npx cap sync") so apple build / play build refresh them`);
|
|
}
|
|
// play build passes -PversionCode / -PversionName: the app module's build file must read them (seen live: a
|
|
// Capacitor app read versionCode from an env var and the AAB stayed at 1)
|
|
if (config.play) {
|
|
const b = config.play.build ?? {};
|
|
try {
|
|
const cap = capacitorProject(ctx.loaded.root, b.dir);
|
|
const sub = !b.dir && cap?.dir ? cap.dir : '';
|
|
const t = pickGradle(sub ? join(ctx.loaded.root, sub) : ctx.loaded.root, { dir: b.dir, module: b.module, flavor: b.flavor });
|
|
const file = t.framework === 'flutter' ? null : appBuildFile(t);
|
|
if (file) for (const i of versionSourceIssues(file)) note(`play: ${versionSourceMessage(i, relative(ctx.loaded.root, file), t.framework)}`);
|
|
} catch { /* no Gradle project here (yet): play build says so */ }
|
|
}
|
|
// first-run gate (name / code entry) in a Play app without reviewer steps: read from the code only when it matters
|
|
let firstRunGate: string[] = [];
|
|
if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim()) {
|
|
try { const f = facts ?? detectProject(ctx.loaded.root); firstRunGate = grepOwn(f, SIGNALS.firstRunGate, 3).hits.map(where); } catch { /* no project sources here */ }
|
|
}
|
|
for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root), firstRunGate })) {
|
|
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
|
|
}
|
|
// remote config / feature flags / remote paywalls not told to review (2.3.1, Play Deceptive behavior): a warning only
|
|
if (config.apple || config.play) {
|
|
try {
|
|
const f = facts ?? detectProject(ctx.loaded.root);
|
|
for (const r of remoteFeatureFindings(f, config, { apple: !!config.apple, play: !!config.play }))
|
|
if (r.severity !== 'low' && !(config.scan?.ignore ?? []).some(i => i.rule === r.rule && (!i.store || i.store === r.store))) note(`${r.store}: ${r.title} (${r.guideline}; ${r.evidence.map(where).join(', ')}). ${r.fix}`);
|
|
} catch { /* no project sources here */ }
|
|
}
|
|
// Game Center entitlement, the PGS APP_ID resource, player-id declarations (offline)
|
|
if (config.games || config.apple || config.play) {
|
|
try { for (const f of localGameFindings(ctx.loaded)) { if (f.level === 'error') problems.push(`${f.store}: ${f.msg} (${f.fix ?? ''})`); else if (f.level === 'warn') note(`${f.store}: ${f.msg}${f.fix ? ` (${f.fix})` : ''}`); } }
|
|
catch { /* no project sources here */ }
|
|
}
|
|
// Play steps without an API: the App category (Store settings) and the App content answers the owner still owes
|
|
const pc = playConsoleReminders(config);
|
|
for (const w of pc.warnings) note(w);
|
|
for (const h of pc.hints) { hints.push(h); log('check', `hint: ${h}`); }
|
|
const subs = allSubscriptions(config.subscriptions);
|
|
if (subs.length) log('check', `subscriptions: ${subs.length} in ${config.subscriptions!.length} group(s)`);
|
|
const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo,
|
|
...(config.products ?? []).map(p => p.reviewScreenshot), ...subs.map(x => x.sub.reviewScreenshot)].filter((f): f is string => !!f);
|
|
const renderSources = new Set([config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo].filter(Boolean));
|
|
for (const f of new Set(files)) if (!existsSync(resolve(f))) {
|
|
if (fromStores && renderSources.has(f)) note(`render source not there: ${f} (only needed to render icons / screenshots again; the store art is pulled)`);
|
|
else problems.push(`missing file: ${f}`);
|
|
}
|
|
// template compliance answers (storebound init) never count as the owner's
|
|
for (const b of templateBlockers(config)) problems.push(b);
|
|
if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8')))
|
|
problems.push('.gitignore does not ignore .storebound/ (credentials live there)');
|
|
|
|
if (ctx.args.has('json')) { printJson({ config: path, ok: !problems.length, items, warnings, hints, problems }); if (problems.length) process.exitCode = 1; return; }
|
|
if (problems.length) { for (const p of problems) console.error(` - ${p}`); throw new Error(`${problems.length} problems`); }
|
|
log('check', 'all good');
|
|
}
|