storebound/cli/check.ts

194 lines
15 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 { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources } from './lib/app-privacy';
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 { reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile';
import { detectProject } from './lib/scan/project';
import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
import { type CapProject, capacitorProject, prebuildFor, staleMessage, webState } from './lib/web-assets';
/** 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`);
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`;
});
// 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;
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);
}
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}`);
}
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`;
});
// 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 */ }
}
for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
}
// 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);
for (const f of new Set(files)) if (!existsSync(resolve(f))) problems.push(`missing file: ${f}`);
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');
}