// 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 { 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 { 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 { 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 { listingFileMentions } from './lib/listing-mentions'; /** 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 { 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`); 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`; }); // 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(?: