// 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 ] [--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-.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 { const { config, root, path } = ctx.loaded; const a = ctx.args; const notes: Partial> = {}; 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> = {}; 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 = 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 { 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`); }