storebound/cli/check.ts

136 lines
10 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, readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { appPrivacyMismatches } from './lib/app-privacy';
import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config';
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';
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>)`);
}
// App Privacy answers (appPrivacy.types) against the iOS project: ATT string, PrivacyInfo.xcprivacy tracking and types
const apTypes = config.appPrivacy?.types;
if (config.apple && apTypes && Object.keys(apTypes).length) {
const facts = detectProject(ctx.loaded.root);
if (!facts.ios) hints.push('appPrivacy.types: no iOS project found here, so it is not compared with Info.plist / PrivacyInfo.xcprivacy');
else {
const own = facts.privacyManifests.find(m => !/(^|\/)(Pods|node_modules|SourcePackages|Carthage|\.build)\//.test(m.path)) ?? null;
const found = appPrivacyMismatches(apTypes, { attString: facts.plist.has('NSUserTrackingUsageDescription'), manifest: own });
for (const f of found) if (f.level === 'error') problems.push(f.msg); else note(f.msg);
items.push({ what: 'app privacy', detail: `${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project` });
log('check', `app privacy: ${Object.keys(apTypes).length} types answered, ${found.length} mismatch(es) with the iOS project`);
}
}
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
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`;
});
for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
}
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');
}