report: storebound report writes one shareable, self-contained readiness page (.storebound/reports/readiness-<date>.html; --md, --json, --no-open, --offline, --out) from scan, check and validate: a verdict per store (Ready to submit / N blockers / Not checked with the reason), findings per store by severity with guideline, evidence, fix and console button, the owner's compliance questions as a checklist, secrets masked; storebound.dev look, light / dark, print; desktop Checks > Report (same code path, opens through project_reveal) and its Markdown export on the same renderer; read-only MCP tool report
This commit is contained in:
parent
6d95ea00f5
commit
af349b3db1
18 changed files with 792 additions and 12 deletions
|
|
@ -29,6 +29,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'inspect': [() => import('./inspect'), 'offline summary of the config (name, store ids, languages, products, subscriptions); --json'],
|
||||
'check': [() => import('./check'), 'validate config, copy files, data safety answers, products, subscriptions and the app profile (offline); --aso keyword audit'],
|
||||
'validate': [() => import('./validate'), 'read-only store readiness report with ordered blockers (--apple / --play), plus local renders checks (--no-local skips); exit 1 on blockers'],
|
||||
'report': [() => import('./report'), 'shareable store readiness report: scan + check + validate (read-only; only the stores with credentials, else it says why) in one self-contained HTML page .storebound/reports/readiness-<date>.html, opened in the browser; --out <file>, --md, --json, --no-open, --offline'],
|
||||
'analytics': [() => import('./analytics'), 'setup (App Store report request) | snapshot (impressions, page views, installs, conversion -> .storebound/analytics) | compare (vs listing changes in git)'],
|
||||
'health': [() => import('./health'), 'crash / ANR vitals (Play) and diagnostics (App Store), read-only; --weekly writes a markdown report; exit 1 over a threshold'],
|
||||
'art': [() => import('./art'), 'pull [--store apple|play] [--write] [--force]: the live store art (App Store screenshot sets, previews, icon; Play icon, feature graphic, screenshots per language) into store/renders + pulled.json, so checks and uploads treat it like rendered art (upsert; differing local files kept without --force; read-only on the stores) | live [--json]: the live art with thumbnails | replace --from <file> --to <path>: one image of the app folder'],
|
||||
|
|
|
|||
|
|
@ -699,6 +699,23 @@
|
|||
"versions",
|
||||
"workspace"
|
||||
],
|
||||
"report": [
|
||||
"capacitor",
|
||||
"cordova",
|
||||
"cordova-android",
|
||||
"cordova-ios",
|
||||
"expo",
|
||||
"flutter",
|
||||
"godot",
|
||||
"md",
|
||||
"name",
|
||||
"no-open",
|
||||
"offline",
|
||||
"out",
|
||||
"react-native",
|
||||
"sku",
|
||||
"unity"
|
||||
],
|
||||
"reports": [
|
||||
"csv",
|
||||
"date",
|
||||
|
|
@ -996,6 +1013,7 @@
|
|||
"no-local",
|
||||
"no-mcp",
|
||||
"no-only-decreases",
|
||||
"no-open",
|
||||
"no-prebuild",
|
||||
"no-save",
|
||||
"no-scan",
|
||||
|
|
@ -1394,6 +1412,9 @@
|
|||
"storebound privacy-manifest --ios ios/App [--sdk-dirs node_modules/@capacitor,node_modules/some-plugin] [--json]",
|
||||
"storebound privacy-manifest --ios ios/App --propose [--tracking-domains a.example.com,b.example.com]"
|
||||
],
|
||||
"report": [
|
||||
"storebound report [--out <file>] [--md] [--json] [--no-open] [--offline]"
|
||||
],
|
||||
"reports": [
|
||||
"storebound reports apple-sales [--type SALES] [--frequency DAILY|WEEKLY|MONTHLY|YEARLY] [--date ...] [--vendor N]",
|
||||
"storebound reports apple-finance --month YYYY-MM [--detail] [--region ZZ] [--vendor N]",
|
||||
|
|
|
|||
330
cli/lib/report.ts
Normal file
330
cli/lib/report.ts
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
// The shareable store readiness report (`storebound report`, the desktop's Report button and its Markdown export, the
|
||||
// MCP `report` tool): one data shape built from `scan`, `check` and `validate`, rendered as a self-contained HTML page
|
||||
// (inline CSS, no scripts, no external requests, light and dark), as Markdown or as JSON. Pure: no file system, no
|
||||
// network, so the desktop window imports it too. Every text goes through maskSecrets: credentials, key ids, emails,
|
||||
// tokens and the review demo account never reach the page (the command also passes the literal values it knows).
|
||||
|
||||
export type ReportStore = 'apple' | 'play' | 'both';
|
||||
export type ReportLevel = 'blocker' | 'warn' | 'ok' | 'info';
|
||||
export type ReportSource = 'scan' | 'check' | 'validate';
|
||||
|
||||
export interface ReportFinding {
|
||||
store: ReportStore;
|
||||
level: ReportLevel;
|
||||
source: ReportSource;
|
||||
/** Rule / check id (scan rule, validate code, "check"). */
|
||||
rule: string;
|
||||
guideline?: string;
|
||||
title: string;
|
||||
/** file:line evidence. */
|
||||
evidence: string[];
|
||||
fix?: string;
|
||||
/** Absolute console deep link. */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface ReportQuestion { store: ReportStore; text: string; source: string; /** Console page where it is answered. */ url?: string }
|
||||
export interface SourceState { ran: boolean; note?: string }
|
||||
export interface StoreSummary { blockers: number; warnings: number; ok: number }
|
||||
|
||||
export interface ReportData {
|
||||
app: { name: string; version?: string; build?: string };
|
||||
/** The stores the config has (the summary shows these and "both"). */
|
||||
stores: ('apple' | 'play')[];
|
||||
/** Per store: was the store itself read (validate), and if not, why. */
|
||||
read: Record<'apple' | 'play', { checked: boolean; why?: string }>;
|
||||
generatedAt: string;
|
||||
storebound: string;
|
||||
sources: Record<ReportSource, SourceState>;
|
||||
summary: Record<ReportStore, StoreSummary>;
|
||||
findings: ReportFinding[];
|
||||
questions: ReportQuestion[];
|
||||
}
|
||||
|
||||
// ---- inputs (the --json shapes of the three commands) ----
|
||||
|
||||
export interface ScanInput {
|
||||
findings?: { rule: string; guideline: string; store: 'apple' | 'play'; severity: 'blocker' | 'high' | 'medium' | 'low'; title: string; evidence?: { file: string; line: number }[]; url?: string; fix: string; skill?: string; accepted?: string }[];
|
||||
declarations?: { items?: { area: string; what: string; value: string; status: 'detected' | 'ask'; note?: string }[] };
|
||||
}
|
||||
export interface CheckInput { ok?: boolean; items?: { what: string; detail: string }[]; warnings?: string[]; hints?: string[]; problems?: string[] }
|
||||
export interface ValidateInput { blockers?: number; findings?: { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; url?: string }[] }
|
||||
|
||||
export interface BuildInput {
|
||||
app: ReportData['app'];
|
||||
stores?: ('apple' | 'play')[];
|
||||
/** Per store: true when validate read it, else the reason it did not. */
|
||||
storeRead?: Partial<Record<'apple' | 'play', true | string>>;
|
||||
generatedAt: string;
|
||||
storebound: string;
|
||||
/** Console deep link for a finding that carries none (cli/lib/console-links.ts, chosen by the command). */
|
||||
linkFor?: (f: ReportFinding) => string | undefined;
|
||||
scan?: ScanInput | null;
|
||||
check?: CheckInput | null;
|
||||
validate?: ValidateInput | null;
|
||||
/** Why a source did not run (e.g. no credentials). */
|
||||
notes?: Partial<Record<ReportSource, string>>;
|
||||
/** Questions from elsewhere (console plans' ask-owner answers). */
|
||||
questions?: ReportQuestion[];
|
||||
/** Literal secret values to mask wherever they appear. */
|
||||
secrets?: string[];
|
||||
}
|
||||
|
||||
const ORDER: ReportLevel[] = ['blocker', 'warn', 'info', 'ok'];
|
||||
const STORES: ReportStore[] = ['apple', 'play', 'both'];
|
||||
export const STORE_LABEL: Record<ReportStore, string> = { apple: 'App Store', play: 'Google Play', both: 'Both stores' };
|
||||
const LEVEL_LABEL: Record<ReportLevel, string> = { blocker: 'Blocker', warn: 'Warning', info: 'Note', ok: 'OK' };
|
||||
/** A message that asks the owner for an answer only they know (compliance). */
|
||||
const ASK_RE = /ask the owner|still to answer|still holds template answers|answers? (?:is|are) not in the config/i;
|
||||
|
||||
/** The store a free-text check line is about, from its prefix. */
|
||||
function storeOf(msg: string): ReportStore {
|
||||
if (/^(play|google play|android)\b/i.test(msg)) return 'play';
|
||||
if (/^(apple|app store|appstore|ios|testflight)\b/i.test(msg)) return 'apple';
|
||||
return 'both';
|
||||
}
|
||||
|
||||
/** Masks anything that looks like a secret: PEM blocks, private key JSON, JWTs, emails, UUIDs (issuer ids), App Store
|
||||
* Connect key ids, long tokens, plus every literal value in `known`. */
|
||||
export function maskSecrets(text: string, known: string[] = []): string {
|
||||
let s = text;
|
||||
for (const k of [...new Set(known)].filter(k => k && k.length >= 4).sort((a, b) => b.length - a.length)) s = s.split(k).join('[hidden]');
|
||||
return s
|
||||
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, '[private key hidden]')
|
||||
.replace(/"private_key(_id)?"\s*:\s*"[^"]*"/g, '"private_key$1": "[hidden]"')
|
||||
.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[token hidden]')
|
||||
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, m => (/@example\.(com|org|net)$/i.test(m) ? m : '[email hidden]'))
|
||||
.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '[id hidden]')
|
||||
.replace(/\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{10}\b/g, '[key id hidden]')
|
||||
.replace(/\b(?:sk|pk|rk|ghp|gho|xox[abp]|AIza)[_-]?[A-Za-z0-9_-]{16,}\b/g, '[token hidden]')
|
||||
.replace(/\b[A-Za-z0-9+/_-]{40,}={0,2}/g, m => (/\d/.test(m) && /[A-Za-z]/.test(m) && !(m.includes('/') && !/[+=]/.test(m)) ? '[token hidden]' : m));
|
||||
}
|
||||
|
||||
const severityLevel = (s: string): ReportLevel => (s === 'blocker' ? 'blocker' : s === 'low' ? 'info' : 'warn');
|
||||
|
||||
/** One report from the three command outputs: findings deduplicated, findings shown on both stores merged into
|
||||
* "both", sorted by severity, the owner's open questions collected. */
|
||||
export function buildReport(i: BuildInput): ReportData {
|
||||
const raw: ReportFinding[] = [];
|
||||
for (const f of i.scan?.findings ?? []) {
|
||||
if (f.accepted) continue;
|
||||
raw.push({ store: f.store, level: severityLevel(f.severity), source: 'scan', rule: f.rule, guideline: f.guideline, title: f.title,
|
||||
evidence: (f.evidence ?? []).slice(0, 5).map(e => `${e.file}:${e.line}`), fix: f.skill ? `${f.fix} (skill: ${f.skill})` : f.fix, ...(f.url ? { url: f.url } : {}) });
|
||||
}
|
||||
const c = i.check;
|
||||
for (const p of c?.problems ?? []) raw.push({ store: storeOf(p), level: 'blocker', source: 'check', rule: 'check', title: p, evidence: [] });
|
||||
for (const w of c?.warnings ?? []) raw.push({ store: storeOf(w), level: 'warn', source: 'check', rule: 'check', title: w, evidence: [] });
|
||||
for (const h of c?.hints ?? []) raw.push({ store: storeOf(h), level: 'info', source: 'check', rule: 'check', title: h, evidence: [] });
|
||||
for (const it of c?.items ?? []) raw.push({ store: storeOf(it.what), level: 'ok', source: 'check', rule: 'check', title: `${it.what}: ${it.detail}`, evidence: [] });
|
||||
for (const f of i.validate?.findings ?? []) {
|
||||
const ev = /\(([^()\s]+:\d+)\)\s*$/.exec(f.msg)?.[1];
|
||||
raw.push({ store: f.store, level: f.level, source: 'validate', rule: f.code, title: f.msg, evidence: ev ? [ev] : [], ...(f.fix ? { fix: f.fix } : {}), ...(f.url ? { url: f.url } : {}) });
|
||||
}
|
||||
// same text on both stores -> one "both" finding; exact duplicates (validate repeats check's template lines) dropped
|
||||
const key = (f: ReportFinding) => `${f.level}|${f.rule}|${f.title}`;
|
||||
const seen = new Map<string, ReportFinding>();
|
||||
for (const f of raw) {
|
||||
const k = key(f);
|
||||
const had = seen.get(k) ?? [...seen.values()].find(x => x.title === f.title && x.level === f.level);
|
||||
if (!had) { seen.set(k, { ...f }); continue; }
|
||||
if (had.store !== f.store) had.store = 'both';
|
||||
if (!had.url && f.url) had.url = f.url;
|
||||
if (!had.fix && f.fix) had.fix = f.fix;
|
||||
}
|
||||
if (i.linkFor) for (const f of seen.values()) if (!f.url && f.level !== 'ok') { const u = i.linkFor(f); if (u) f.url = u; }
|
||||
const findings = [...seen.values()].sort((a, b) => ORDER.indexOf(a.level) - ORDER.indexOf(b.level) || STORES.indexOf(a.store) - STORES.indexOf(b.store));
|
||||
|
||||
const questions: ReportQuestion[] = [];
|
||||
const ask = (q: ReportQuestion) => { if (!questions.some(x => x.text === q.text)) questions.push(q); };
|
||||
for (const d of i.scan?.declarations?.items ?? []) if (d.status === 'ask' && d.area !== 'privacyManifest') ask({ store: d.area === 'appPrivacy' || d.area === 'att' ? 'apple' : d.area === 'dataSafety' ? 'play' : 'both', text: `${d.what}: ${d.value}${d.note ? ` (${d.note})` : ''}`, source: 'scan' });
|
||||
for (const f of findings) if (f.level !== 'ok' && ASK_RE.test(f.title)) ask({ store: f.store, text: f.title, source: f.source });
|
||||
for (const q of i.questions ?? []) ask(q);
|
||||
|
||||
const summary = Object.fromEntries(STORES.map(s => [s, { blockers: 0, warnings: 0, ok: 0 }])) as Record<ReportStore, StoreSummary>;
|
||||
for (const f of findings) {
|
||||
const s = summary[f.store];
|
||||
if (f.level === 'blocker') s.blockers++; else if (f.level === 'warn') s.warnings++; else if (f.level === 'ok') s.ok++;
|
||||
}
|
||||
const sources: Record<ReportSource, SourceState> = {
|
||||
scan: { ran: !!i.scan, ...(i.notes?.scan ? { note: i.notes.scan } : {}) },
|
||||
check: { ran: !!i.check, ...(i.notes?.check ? { note: i.notes.check } : {}) },
|
||||
validate: { ran: !!i.validate, ...(i.notes?.validate ? { note: i.notes.validate } : {}) },
|
||||
};
|
||||
const stores = i.stores ?? (['apple', 'play'] as const).filter(s => raw.some(f => f.store === s));
|
||||
const readOf = (st: 'apple' | 'play') => { const v = i.storeRead?.[st]; return v === true ? { checked: true } : { checked: false, ...(v ? { why: v } : {}) }; };
|
||||
return maskReport({ app: i.app, stores, read: { apple: readOf('apple'), play: readOf('play') }, generatedAt: i.generatedAt, storebound: i.storebound, sources, summary, findings, questions }, i.secrets ?? []);
|
||||
}
|
||||
|
||||
/** The report with every string masked (maskSecrets); urls only when they are https. */
|
||||
export function maskReport(r: ReportData, known: string[] = []): ReportData {
|
||||
const m = (s: string) => maskSecrets(s, known);
|
||||
const mo = <T extends string | undefined>(s: T): T => (s === undefined ? s : m(s) as T);
|
||||
const safeUrl = (u?: string) => (u && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : undefined);
|
||||
return {
|
||||
...r,
|
||||
app: { name: m(r.app.name), ...(r.app.version ? { version: m(r.app.version) } : {}), ...(r.app.build ? { build: m(r.app.build) } : {}) },
|
||||
read: { apple: { ...r.read.apple, ...(r.read.apple.why ? { why: m(r.read.apple.why) } : {}) }, play: { ...r.read.play, ...(r.read.play.why ? { why: m(r.read.play.why) } : {}) } },
|
||||
sources: Object.fromEntries(Object.entries(r.sources).map(([k, v]) => [k, { ran: v.ran, ...(v.note ? { note: m(v.note) } : {}) }])) as ReportData['sources'],
|
||||
findings: r.findings.map(f => {
|
||||
const url = safeUrl(f.url);
|
||||
const { url: _drop, ...rest } = f;
|
||||
return { ...rest, title: m(f.title), guideline: mo(f.guideline), fix: mo(f.fix), evidence: f.evidence.map(m), ...(url ? { url } : {}) };
|
||||
}),
|
||||
questions: r.questions.map(q => { const url = safeUrl(q.url); const { url: _u, ...rest } = q; return { ...rest, text: m(q.text), ...(url ? { url } : {}) }; }),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- renderers ----
|
||||
|
||||
export const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
/** The summary columns: both stores always (a store the config lacks shows "Not set up"), plus "both" when anything is on both. */
|
||||
const shown = (r: ReportData): ReportStore[] => ['apple', 'play', ...(r.findings.some(f => f.store === 'both') ? ['both' as const] : [])];
|
||||
const appLine = (r: ReportData) => [r.app.version && `version ${r.app.version}`, r.app.build && `build ${r.app.build}`].filter(Boolean).join(' · ');
|
||||
const sourceLine = (name: ReportSource, s: SourceState) => `${name}: ${s.ran ? 'ran' : 'skipped'}${s.note ? ` (${s.note})` : ''}`;
|
||||
|
||||
// storebound.dev's look (site/style.css tokens): slate ink, the indigo accent, the system font stack, 14px radius.
|
||||
const CSS = `:root{color-scheme:light dark;--bg:#f8fafc;--surface:#fff;--ink:#0f172a;--ink-2:#334155;--muted:#64748b;--line:#e2e8f0;--accent:#4f46e5;--accent-ink:#fff;--accent-soft:#eef2ff;
|
||||
--bad-bg:#fee2e2;--bad:#991b1b;--warn-bg:#fef3c7;--warn:#92400e;--note-bg:#eef2ff;--note:#3730a3;--ok-bg:#dcfce7;--ok:#166534;--radius:14px;
|
||||
--shadow:0 1px 2px rgba(15,23,42,.06),0 8px 24px -12px rgba(15,23,42,.18);--font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#0b1120;--surface:#131c2f;--ink:#f1f5f9;--ink-2:#cbd5e1;--muted:#94a3b8;--line:#1e293b;--accent:#818cf8;--accent-ink:#0b1120;--accent-soft:#1e1b4b;
|
||||
--bad-bg:#450a0a;--bad:#fca5a5;--warn-bg:#451a03;--warn:#fcd34d;--note-bg:#1e1b4b;--note:#c7d2fe;--ok-bg:#14532d;--ok:#86efac;--shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px -16px rgba(0,0,0,.7)}}
|
||||
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--font);font-variant-numeric:tabular-nums}
|
||||
main{max-width:1180px;margin:0 auto;padding:40px 16px 56px}
|
||||
.small,.muted,dl,.tag,.pill,footer,summary{font-size:13px}.muted{color:var(--muted)}
|
||||
header.top{display:flex;gap:16px;align-items:center;margin-bottom:28px}
|
||||
header.top img{width:64px;height:64px;border-radius:15px;box-shadow:var(--shadow);flex:none}
|
||||
h1{font-size:28px;line-height:1.2;margin:0 0 4px;letter-spacing:-.01em}h2{font-size:20px;line-height:1.3;margin:0 0 4px}h3{font-size:15px;margin:0}
|
||||
.verdicts{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin:0 0 40px}
|
||||
.verdict{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow)}
|
||||
.verdict .big{font-size:26px;font-weight:700;line-height:1.25;margin:6px 0 8px}
|
||||
.verdict.bad .big{color:var(--bad)}.verdict.ok .big{color:var(--ok)}.verdict.none .big{color:var(--muted)}
|
||||
.counts{display:flex;gap:16px;flex-wrap:wrap;color:var(--ink-2);font-size:13px}.counts b{font-weight:600}
|
||||
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:32px;align-items:start}
|
||||
section.store{min-width:0;margin-bottom:40px}section.store>.head{margin-bottom:16px}
|
||||
.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin:0 0 12px;overflow-wrap:anywhere}
|
||||
.card .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px}
|
||||
.pill{display:inline-block;font-weight:600;padding:2px 10px;border-radius:999px;line-height:1.5}
|
||||
.pill.blocker{background:var(--bad-bg);color:var(--bad)}.pill.warn{background:var(--warn-bg);color:var(--warn)}.pill.info{background:var(--note-bg);color:var(--note)}.pill.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.tag{color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 9px}
|
||||
.title{font-weight:600;margin:0 0 6px}.fix{color:var(--ink-2);margin:6px 0 0}
|
||||
.ev{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 0}code{font:13px/1.5 var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:1px 6px}
|
||||
.btn{display:inline-block;margin-top:12px;background:var(--accent);color:var(--accent-ink);text-decoration:none;font-weight:600;font-size:13px;padding:7px 14px;border-radius:10px}
|
||||
.btn:hover{filter:brightness(1.08)}.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.clean{background:var(--surface);border:1px dashed var(--line);border-radius:var(--radius);padding:20px;color:var(--muted)}
|
||||
details{margin:8px 0 12px}summary{cursor:pointer;color:var(--ink-2);font-weight:600;padding:6px 0}details .card{margin-top:8px}
|
||||
.q{list-style:none;padding:0;margin:12px 0 0;display:grid;gap:10px}
|
||||
.q li{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:12px 16px}
|
||||
.q label{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.q input{width:18px;height:18px;margin:3px 0 0;accent-color:var(--accent);flex:none}
|
||||
.q a{color:var(--accent);font-size:13px;font-weight:600}
|
||||
.questions{margin-top:8px}.qcols{margin-top:20px}.qcol h3{margin-bottom:4px}footer{margin-top:48px;color:var(--muted);border-top:1px solid var(--line);padding-top:16px}
|
||||
@media (max-width:560px){main{padding-top:24px}h1{font-size:24px}header.top img{width:48px;height:48px;border-radius:11px}}
|
||||
@media print{:root{color-scheme:light}body{background:#fff}.btn,.q a{display:none}.card,.verdict,.q li{box-shadow:none;break-inside:avoid}
|
||||
.cols{display:block}section.store+section.store,.questions{break-before:page}details>*{display:block}main{padding:0}}`;
|
||||
|
||||
const PILL: Record<ReportLevel, string> = { blocker: 'Blocker', warn: 'Warning', info: 'Note', ok: 'Passed' };
|
||||
const consoleName = (url: string) => (/appstoreconnect\.apple\.com|developer\.apple\.com/.test(url) ? 'App Store Connect' : /play\.google\.com/.test(url) ? 'Play Console' : 'the console');
|
||||
|
||||
function findingHtml(f: ReportFinding): string {
|
||||
const e = escapeHtml;
|
||||
const tags = [f.guideline && `<span class="tag">${e(f.guideline)}</span>`, `<span class="tag">${e(f.rule)}</span>`].filter(Boolean).join('');
|
||||
return `<article class="card ${f.level}"><div class="row"><span class="pill ${f.level}">${PILL[f.level]}</span>${tags}</div>`
|
||||
+ `<p class="title">${e(f.title)}</p>`
|
||||
+ (f.evidence.length ? `<div class="ev">${f.evidence.map(x => `<code>${e(x)}</code>`).join('')}</div>` : '')
|
||||
+ (f.fix ? `<p class="fix">${e(f.fix)}</p>` : '')
|
||||
+ (f.url ? `<a class="btn" href="${e(f.url)}" rel="noreferrer noopener" target="_blank">Open in ${consoleName(f.url)}</a>` : '')
|
||||
+ '</article>';
|
||||
}
|
||||
|
||||
/** The verdict of one store: blockers first, then whether the store itself was read. */
|
||||
function verdictOf(r: ReportData, s: ReportStore): { cls: string; big: string; sub: string } {
|
||||
const x = r.summary[s];
|
||||
const read = s === 'both' ? undefined : r.read[s];
|
||||
const notRead = read && !read.checked ? `Store not read${read.why ? `: ${read.why}` : ''}` : '';
|
||||
if (x.blockers) return { cls: 'bad', big: `${x.blockers} blocker${x.blockers === 1 ? '' : 's'}`, sub: notRead };
|
||||
if (read && !read.checked && /section in the config/.test(read.why ?? '')) return { cls: 'none', big: 'Not set up', sub: `Not checked: ${read.why} (storebound init adds it)` };
|
||||
if (notRead) return { cls: 'none', big: `Not checked (${read?.why && /credential|key|service account/i.test(read.why) ? 'no credentials' : read?.why ?? 'not read'})`, sub: `Store not read: ${read?.why ?? 'unknown'}. Offline checks found no blockers.` };
|
||||
return { cls: 'ok', big: s === 'both' ? 'No shared blockers' : 'Ready to submit', sub: '' };
|
||||
}
|
||||
|
||||
export interface HtmlOptions { /** The app icon as a data: URL (PNG / JPEG / WebP only). */ icon?: string | null }
|
||||
const ICON_RE = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
/** The self-contained HTML page: inline CSS, no script, no external request. */
|
||||
export function renderHtml(r: ReportData, o: HtmlOptions = {}): string {
|
||||
const e = escapeHtml;
|
||||
const cols = shown(r);
|
||||
const verdict = (s: ReportStore) => {
|
||||
const v = verdictOf(r, s), x = r.summary[s];
|
||||
return `<div class="verdict ${v.cls}"><h3>${STORE_LABEL[s]}</h3><div class="big">${e(v.big)}</div>`
|
||||
+ `<div class="counts"><span><b>${x.blockers}</b> blockers</span><span><b>${x.warnings}</b> warnings</span><span><b>${x.ok}</b> passed</span></div>`
|
||||
+ (v.sub ? `<div class="muted" style="margin-top:8px">${e(v.sub)}</div>` : '') + '</div>';
|
||||
};
|
||||
const section = (s: ReportStore) => {
|
||||
const open = r.findings.filter(f => f.store === s && (f.level === 'blocker' || f.level === 'warn'));
|
||||
const notes = r.findings.filter(f => f.store === s && f.level === 'info');
|
||||
const ok = r.findings.filter(f => f.store === s && f.level === 'ok');
|
||||
if (s === 'both' && !open.length && !notes.length && !ok.length) return '';
|
||||
const head = `<div class="head"><h2>${STORE_LABEL[s]}</h2><div class="muted">${open.length ? `${open.length} to fix, most severe first` : 'Nothing to fix'}</div></div>`;
|
||||
const fold = (label: string, list: ReportFinding[]) => (list.length ? `<details><summary>${list.length} ${label}</summary>${list.map(findingHtml).join('')}</details>` : '');
|
||||
const empty = s !== 'both' && !r.stores.includes(s) ? `Not set up: ${r.read[s].why ?? 'not in the config'}.` : 'No blockers or warnings.';
|
||||
return `<section class="store">${head}${open.map(findingHtml).join('') || `<div class="clean">${e(empty)}</div>`}${fold(notes.length === 1 ? 'note' : 'notes', notes)}${fold('passed', ok)}</section>`;
|
||||
};
|
||||
const both = cols.includes('both') ? section('both') : '';
|
||||
const stores = cols.filter(s => s !== 'both');
|
||||
const meta = [r.app.version && `Version ${r.app.version}`, r.app.build && `build ${r.app.build}`, `checked ${r.generatedAt.slice(0, 10)}`].filter(Boolean).join(' · ');
|
||||
const icon = o.icon && ICON_RE.test(o.icon) ? `<img src="${o.icon}" alt="">` : '';
|
||||
const qList = (st: ReportStore) => {
|
||||
const list = r.questions.filter(x => x.store === st);
|
||||
if (st === 'both' && !list.length) return '';
|
||||
const none = st !== 'both' && !r.stores.includes(st) ? 'Not set up in this config.' : 'Nothing open: every answer is in the config.';
|
||||
return `<div class="qcol"><h3>${STORE_LABEL[st]}</h3>${list.length
|
||||
? `<ul class="q">${list.map(x => `<li><label><input type="checkbox"><span>${e(x.text)}${x.url ? ` <a href="${e(x.url)}" rel="noreferrer noopener" target="_blank">Open in ${consoleName(x.url)}</a>` : ''}</span></label></li>`).join('')}</ul>`
|
||||
: `<div class="clean">${none}</div>`}</div>`;
|
||||
};
|
||||
const q = `<div class="cols qcols">${(['apple', 'play', 'both'] as const).map(qList).join('')}</div>`;
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">
|
||||
<meta name="referrer" content="no-referrer"><meta name="color-scheme" content="light dark"><title>${e(r.app.name)}: store readiness</title><style>${CSS}</style></head>
|
||||
<body><main>
|
||||
<header class="top">${icon}<div><h1>${e(r.app.name)}</h1><div class="muted">${e(meta)}</div></div></header>
|
||||
<div class="verdicts">${cols.map(verdict).join('')}</div>
|
||||
${both}
|
||||
<div class="cols">${stores.map(section).join('')}</div>
|
||||
<section class="questions"><h2>Questions only you can answer</h2><div class="muted">Compliance facts no code or config can prove. Answer them once; storebound writes them into the config.</div>${q}</section>
|
||||
<footer>storebound ${e(r.storebound)} · ${e(r.generatedAt)} · ${(Object.keys(r.sources) as ReportSource[]).map(k => e(sourceLine(k, r.sources[k]))).join(' · ')} · Read-only: nothing was sent to a store.</footer>
|
||||
</main></body></html>
|
||||
`;
|
||||
}
|
||||
|
||||
const mdCell = (s: string) => s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
||||
|
||||
/** The same report as Markdown (desktop export, `--md`). */
|
||||
export function renderMarkdown(r: ReportData): string {
|
||||
const out: string[] = [`# ${r.app.name}: store readiness`, ''];
|
||||
const meta = appLine(r);
|
||||
if (meta) out.push(meta, '');
|
||||
out.push('| store | verdict | blockers | warnings | passed |', '|---|---|---|---|---|', ...shown(r).map(s => `| ${STORE_LABEL[s]} | ${mdCell(verdictOf(r, s).big)} | ${r.summary[s].blockers} | ${r.summary[s].warnings} | ${r.summary[s].ok} |`), '');
|
||||
out.push((Object.keys(r.sources) as ReportSource[]).map(k => sourceLine(k, r.sources[k])).join(' · '), '');
|
||||
for (const s of STORES) {
|
||||
const list = r.findings.filter(f => f.store === s && f.level !== 'ok');
|
||||
if (!list.length) continue;
|
||||
out.push(`## ${STORE_LABEL[s]}`, '', '| level | rule | guideline | finding | evidence | fix | console |', '|---|---|---|---|---|---|---|');
|
||||
for (const f of list) out.push(`| ${LEVEL_LABEL[f.level]} | ${mdCell(f.rule)} | ${mdCell(f.guideline ?? '')} | ${mdCell(f.title)} | ${mdCell(f.evidence.join(', '))} | ${mdCell(f.fix ?? '')} | ${f.url ? `[open](${f.url})` : ''} |`);
|
||||
out.push('');
|
||||
}
|
||||
out.push('## Questions only you can answer', '', ...(r.questions.length ? r.questions.map(q => `- [ ] **${STORE_LABEL[q.store]}:** ${q.text}${q.url ? ` ([open](${q.url}))` : ''}`) : ['None open.']), '');
|
||||
out.push(`storebound ${r.storebound} · ${r.generatedAt}`);
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/** Short summary line for logs and the MCP result. */
|
||||
export function summaryText(r: ReportData): string {
|
||||
return shown(r).map(s => {
|
||||
const v = verdictOf(r, s), x = r.summary[s], read = s === 'both' ? undefined : r.read[s];
|
||||
const extra = [x.warnings ? `${x.warnings} warning(s)` : '', x.blockers && read && !read.checked ? `store not read: ${read.why ?? 'unknown'}` : ''].filter(Boolean).join(', ');
|
||||
return `${STORE_LABEL[s]}: ${v.big}${extra ? ` (${extra})` : ''}`;
|
||||
}).join(' · ');
|
||||
}
|
||||
|
|
@ -57,6 +57,10 @@ export const TOOLS: ToolDef[] = [
|
|||
description: 'Read-only readiness report for App Store Connect and/or Google Play with ordered blockers (build, encryption, metadata, screenshots, review details and demo account, age rating, prices, IAPs, subscriptions, privacy, delete-account and terms URLs, app-ads.txt, tracks, products, base plans).',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' } },
|
||||
args: i => (i.store === 'apple' ? ['--apple'] : i.store === 'play' ? ['--play'] : []) },
|
||||
{ name: 'report', title: 'Shareable readiness report', command: ['report'], write: false, network: true,
|
||||
description: 'One self-contained HTML page with the scan, check and validate results (read-only; validate only for the stores whose credentials are there, else the page says why): summary per store, findings by store and severity with guideline, evidence, fix and console link, the compliance questions only the owner can answer. Writes only .storebound/reports/readiness-<date>.html (masked: no credentials, key ids, emails or demo account) and returns its path and the summary; never opens a browser.',
|
||||
props: { format: { type: 'string', enum: ['html', 'md'], default: 'html' }, offline: { type: 'boolean', default: false, description: 'Skip validate (no store reads)' } },
|
||||
args: i => ['--no-open', ...(i.format === 'md' ? ['--md'] : []), ...(i.offline === true ? ['--offline'] : [])] },
|
||||
{ name: 'health', title: 'Crash and vitals health', command: ['health'], write: false, network: true,
|
||||
description: 'Read-only crash / ANR vitals from the Play Developer Reporting API (user-perceived rates vs the bad behavior thresholds 1.09 % / 0.47 %, worst device models, top issues) and App Store power and performance metrics, diagnostic signatures and TestFlight crash reports. Exit 1 when a metric is over a store threshold.',
|
||||
props: { store: { type: 'string', enum: ['both', 'apple', 'play'], default: 'both' }, days: { type: 'integer', minimum: 1, maximum: 90, default: 7 } },
|
||||
|
|
|
|||
182
cli/report.ts
Normal file
182
cli/report.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// 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`);
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ export const DESKTOP_EXCLUDED = new Set(['play_release_testing', 'apple_phased_r
|
|||
export const FORBIDDEN_METHOD_WORDS = /submit|release|publish|rollout|resume|complete|send|start|stop|cancel|delete|remove|invite/i;
|
||||
|
||||
/** Read tools whose command prints a structured result with --json. */
|
||||
export const JSON_TOOLS = new Set(['check', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
|
||||
export const JSON_TOOLS = new Set(['check', 'report', 'check_aso', 'aso_audit', 'aso_suggest', 'aso_competitors', 'scan', 'validate', 'health', 'health_issue', 'reviews_list', 'prices_diff', 'analytics_compare', 'apple_status',
|
||||
'apple_next_build', 'play_status', 'play_next_version_code', 'shots_check', 'apple_testflight_status', 'apple_ppo_status', 'catalog_diff',
|
||||
'project_icon', 'project_listings', 'reviews_drafts', 'project_declarations', 'project_listing_text', 'project_renders', 'project_render', 'prices_current', 'project_editable', 'listing_live', 'art_live', 'art_thumb']);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue