storebound/cli/report.ts

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`);
}