182 lines
12 KiB
TypeScript
182 lines
12 KiB
TypeScript
// A shareable store readiness report: one self-contained HTML page (or Markdown / JSON) anyone can open, no terminal
|
|
// skills and no AI agent needed.
|
|
// storebound report [--out <file>] [--md] [--json] [--no-open] [--offline]
|
|
// Gathers `scan` (offline), `check` (offline) and `validate` (read-only, both stores; only for the stores whose
|
|
// credentials are there, otherwise the report says it was skipped and why; --offline skips it) and writes
|
|
// .storebound/reports/readiness-<date>.html (--md: .md). Then opens it in the default browser unless --no-open, --json,
|
|
// not a terminal, CI or the desktop sidecar. --json prints { file, ...report } on stdout. The only write is the report
|
|
// file. Credentials, key ids, emails, tokens and the review demo account are masked (cli/lib/report.ts).
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
import pkg from '../package.json' with { type: 'json' };
|
|
import { consolePlan } from './lib/console-plan';
|
|
import { type ConsolePage, consoleUrl } from './lib/console-links';
|
|
import type { PlanFormId } from './lib/console-answers';
|
|
import type { ShipConfig } from './lib/config';
|
|
import { CREDENTIAL_KEYS, credentialStatus, credentialValue, reviewContact, reviewDemoAccount } from './lib/credentials';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { log, printJson, warn } from './lib/log';
|
|
import { buildReport, type CheckInput, type ReportData, type ReportFinding, type ReportQuestion, renderHtml, renderMarkdown, type ScanInput, summaryText, type ValidateInput } from './lib/report';
|
|
import { findIcon, iconDataUrl } from './lib/project-files';
|
|
import { runCommand } from './lib/runner';
|
|
import * as rt from './lib/rt';
|
|
|
|
const TAG = 'report';
|
|
/** Console plans whose ask-owner answers are compliance questions. */
|
|
const QUESTION_FORMS: { form: PlanFormId; store: 'apple' | 'play'; all?: boolean }[] = [
|
|
{ form: 'apple-app-privacy', store: 'apple' }, { form: 'apple-age-rating', store: 'apple' },
|
|
{ form: 'play-content-rating', store: 'play' }, { form: 'play-target-audience', store: 'play' }, { form: 'play-app-content', store: 'play', all: true },
|
|
];
|
|
|
|
/** Version and build of the project, when a project file says them (package.json, pubspec.yaml, the Android app module). */
|
|
export function projectVersion(root: string): { version?: string; build?: string } {
|
|
const read = (p: string) => { try { return readFileSync(join(root, p), 'utf8'); } catch { return null; } };
|
|
const pub = read('pubspec.yaml');
|
|
const pm = pub && /^version:\s*([0-9][^\s+]*)(?:\+(\d+))?/m.exec(pub);
|
|
if (pm) return { version: pm[1], ...(pm[2] ? { build: pm[2] } : {}) };
|
|
for (const g of ['android/app/build.gradle', 'android/app/build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts']) {
|
|
const t = read(g);
|
|
const name = t && /versionName\s*=?\s*["']([^"']+)["']/.exec(t)?.[1];
|
|
if (name) { const code = /versionCode\s*=?\s*(\d+)/.exec(t!)?.[1]; return { version: name, ...(code ? { build: code } : {}) }; }
|
|
}
|
|
const pj = read('package.json');
|
|
if (pj) { try { const v = (JSON.parse(pj) as { version?: unknown }).version; if (typeof v === 'string' && v && v !== '0.0.0') return { version: v }; } catch { /* not JSON */ } }
|
|
return {};
|
|
}
|
|
|
|
/** Every secret value this project can see (to mask literally): credentials, the review contact and demo account. */
|
|
export function knownSecrets(root: string): string[] {
|
|
const out: string[] = [];
|
|
for (const k of CREDENTIAL_KEYS) { try { const v = credentialValue(root, k); if (v) out.push(v); } catch { /* unreadable */ } }
|
|
try { const d = reviewDemoAccount(root); if (d) out.push(d.username, d.password); } catch { /* none */ }
|
|
try { const c = reviewContact(root); if (c) out.push(c.email, c.phone, c.firstName, c.lastName); } catch { /* none */ }
|
|
return out.filter(v => typeof v === 'string' && v.length >= 4);
|
|
}
|
|
|
|
/** Which stores `validate` can read with the credentials here, and why the others are skipped. */
|
|
export function validateStores(root: string, config: ShipConfig): { apple: boolean; play: boolean; skipped: string[] } {
|
|
const st = new Map(credentialStatus(root).map(c => [c.key, c.status]));
|
|
const set = (k: string) => st.get(k) === 'set';
|
|
const skipped: string[] = [];
|
|
const apple = !!config.apple && set('ASC_KEY_ID') && set('ASC_ISSUER_ID');
|
|
const play = !!config.play && set('PLAY_SERVICE_ACCOUNT');
|
|
if (config.apple && !apple) skipped.push('App Store: no App Store Connect API key (ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8 in env, .storebound/credentials.json or a credential profile)');
|
|
if (config.play && !play) skipped.push('Google Play: no service account (PLAY_SERVICE_ACCOUNT in env, .storebound/credentials.json or a credential profile)');
|
|
return { apple, play, skipped };
|
|
}
|
|
|
|
/** The compliance answers the console plans still need from the owner. */
|
|
function planQuestions(config: ShipConfig, root: string): ReportQuestion[] {
|
|
const out: ReportQuestion[] = [];
|
|
let demo = false;
|
|
try { demo = !!reviewDemoAccount(root); } catch { /* none */ }
|
|
for (const q of QUESTION_FORMS) {
|
|
if (!config[q.store]) continue;
|
|
try {
|
|
const pl = consolePlan(q.form, config, { demoAccount: demo }, { all: !!q.all });
|
|
const url = pl.start.url;
|
|
// many confirmations of one form (age rating answers left at the default): one line with their names
|
|
if (pl.askOwner.length > 3) {
|
|
const names = pl.askOwner.map(x => x.split(': ask the owner')[0]!.split(':').pop()!.trim()).filter(Boolean);
|
|
out.push({ store: q.store, text: `${pl.title}: ${pl.askOwner.length} answers to give or confirm (${names.join(', ')})`, source: 'console plan', url });
|
|
} else for (const x of pl.askOwner) out.push({ store: q.store, text: `${pl.title}: ${x}`, source: 'console plan', url });
|
|
} catch { /* template answers: check reports them */ }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The --json result of one storebound command run as a child (same binary), or the reason it gave none. */
|
|
async function child(argv: string[], cwd: string): Promise<{ data: unknown; error?: string }> {
|
|
const r = await runCommand(argv, { cwd, machine: true }).result;
|
|
if (r.data !== undefined) return { data: r.data };
|
|
return { data: null, error: (r.err || r.out).split('\n').filter(Boolean).pop()?.replace(/^error:\s*/, '').slice(0, 300) || `exit ${r.code}` };
|
|
}
|
|
|
|
/** A console page for a finding without a link of its own, by what its rule is about. */
|
|
export function linkFor(f: ReportFinding): string | undefined {
|
|
if (f.store === 'both') return undefined;
|
|
const t = `${f.rule} ${f.title}`.toLowerCase();
|
|
const pick = (apple: ConsolePage, play: ConsolePage) => consoleUrl(f.store === 'apple' ? apple : play);
|
|
if (/privacy|tracking|\batt\b|data safety|data-safety|label/.test(t)) return pick('asc.app-privacy', 'play.data-safety');
|
|
if (/demo|sign-in|login|review access|app access/.test(t)) return pick('asc.app-review', 'play.app-access');
|
|
if (/subscription/.test(t)) return pick('asc.subscriptions', 'play.subscriptions');
|
|
if (/purchase|iap|product|restore/.test(t)) return pick('asc.iaps', 'play.products');
|
|
if (/age rating|content rating|target audience|families|kids/.test(t)) return pick('asc.age-rating', 'play.target-audience');
|
|
if (/encryption/.test(t)) return pick('asc.encryption', 'play.app-content');
|
|
if (/listing|keyword|subtitle|description|screenshot|metadata/.test(t)) return pick('asc.version', 'play.store-listing');
|
|
if (/target ?sdk|16 ?kb|bundle|manifest|permission/.test(t) && f.store === 'play') return consoleUrl('play.bundle-explorer');
|
|
return pick('asc.version', 'play.app-content');
|
|
}
|
|
|
|
const localDate = (d = new Date()): string => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
|
|
export interface ReportRun { file: string; report: ReportData }
|
|
|
|
/** Gathers the three sources and writes the report file. Shared by the command, the desktop (serve) and MCP. */
|
|
export async function makeReport(ctx: Ctx): Promise<ReportRun> {
|
|
const { config, root, path } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const notes: Partial<Record<'scan' | 'check' | 'validate', string>> = {};
|
|
const cfg = ['--config', path];
|
|
log(TAG, 'project scan (offline)');
|
|
let scan: ScanInput | null = null;
|
|
try { scan = (await (await import('./lib/scan')).runScan(root, { config: path })).report as unknown as ScanInput; } catch (e) { notes.scan = `failed: ${(e as Error).message.slice(0, 200)}`; }
|
|
log(TAG, 'config check (offline)');
|
|
const c = await child(['check', '--json', ...cfg], root);
|
|
const check = c.data as CheckInput | null;
|
|
if (!check) notes.check = `failed: ${c.error}`;
|
|
let validate: ValidateInput | null = null;
|
|
const storeRead: Partial<Record<'apple' | 'play', true | string>> = {};
|
|
if (a.has('offline')) { notes.validate = '--offline'; storeRead.apple = config.apple ? 'skipped with --offline' : 'no apple section in the config'; storeRead.play = config.play ? 'skipped with --offline' : 'no play section in the config'; }
|
|
else {
|
|
const v = validateStores(root, config);
|
|
if (!v.apple) storeRead.apple = config.apple ? 'no App Store Connect API key' : 'no apple section in the config';
|
|
if (!v.play) storeRead.play = config.play ? 'no Play service account' : 'no play section in the config';
|
|
if (!v.apple && !v.play) notes.validate = v.skipped.join('; ') || 'no store in the config';
|
|
else {
|
|
log(TAG, `store readiness (read-only): ${[v.apple && 'App Store', v.play && 'Google Play'].filter(Boolean).join(' + ')}`);
|
|
const r = await child(['validate', '--json', '--no-scan', ...(v.apple && v.play ? [] : v.apple ? ['--apple'] : ['--play']), ...cfg], root);
|
|
validate = r.data as ValidateInput | null;
|
|
if (!validate) { notes.validate = `failed: ${r.error}`; if (v.apple) storeRead.apple = 'the store read failed'; if (v.play) storeRead.play = 'the store read failed'; }
|
|
else {
|
|
if (v.apple) storeRead.apple = true;
|
|
if (v.play) storeRead.play = true;
|
|
if (v.skipped.length) notes.validate = `skipped ${v.skipped.join('; ')}`;
|
|
}
|
|
}
|
|
}
|
|
const report = buildReport({
|
|
app: { name: config.app.name, ...projectVersion(root) }, stores: (['apple', 'play'] as const).filter(s => !!config[s]), linkFor, storeRead, generatedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), storebound: pkg.version,
|
|
scan, check, validate, notes, questions: planQuestions(config, root), secrets: knownSecrets(root),
|
|
});
|
|
const md = a.has('md');
|
|
const file = resolve(root, a.get('out') ?? join('.storebound', 'reports', `readiness-${localDate()}.${md ? 'md' : 'html'}`));
|
|
mkdirSync(dirname(file), { recursive: true });
|
|
let icon: string | null = null;
|
|
try { icon = iconDataUrl(findIcon(config, root)).dataUrl; } catch { /* no icon */ }
|
|
writeFileSync(file, md ? renderMarkdown(report) : renderHtml(report, { icon }));
|
|
return { file, report };
|
|
}
|
|
|
|
/** Opens a file in the default browser / viewer (macOS open, Windows start, Linux xdg-open). */
|
|
export function openFile(file: string): boolean {
|
|
const argv = process.platform === 'darwin' ? ['open', file] : process.platform === 'win32' ? ['cmd', '/c', 'start', '""', file] : ['xdg-open', file];
|
|
try { const p = rt.spawn(argv, { stdin: 'ignore', stdout: 'ignore', stderr: 'ignore' }); void p.exited.catch(() => {}); return true; } catch { return false; }
|
|
}
|
|
|
|
/** Open the report: not with --no-open / --json, not without a terminal, not in CI or under the desktop sidecar. */
|
|
export function shouldOpen(args: { has(k: string): boolean }, env: Record<string, string | undefined> = process.env, tty = !!process.stdout.isTTY): boolean {
|
|
return !args.has('no-open') && !args.has('json') && tty && !env.CI && env.STOREBOUND_SERVE !== '1';
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { file, report } = await makeReport(ctx);
|
|
const shown = relative(process.cwd(), file) || file;
|
|
if (ctx.args.has('json')) { printJson({ file, path: relative(ctx.loaded.root, file).split('\\').join('/'), ...report }); return; }
|
|
log(TAG, summaryText(report));
|
|
if (report.questions.length) log(TAG, `${report.questions.length} question(s) only you can answer are listed in the report`);
|
|
if (report.sources.validate.note) warn(TAG, `validate: ${report.sources.validate.note}`);
|
|
log(TAG, `wrote ${file}`);
|
|
if (shouldOpen(ctx.args)) { if (!openFile(file)) warn(TAG, `could not open ${shown}: open it in a browser`); }
|
|
if (!existsSync(file)) throw new Error(`${shown} was not written`);
|
|
}
|