diff --git a/CHANGELOG.md b/CHANGELOG.md index 128d4c5..4642fa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +- **Shareable readiness report.** `storebound report` puts `scan`, `check` and `validate` into one self-contained HTML + page (`.storebound/reports/readiness-.html`, inline CSS, no scripts, light / dark, print-friendly) and opens it + in the browser: a verdict per store ("Ready to submit", "N blockers", "Not checked" with the reason when a store key + is missing), findings per store by severity with guideline, evidence, fix and an Open in App Store Connect / Play + Console button, and the questions only the owner can answer as a checklist. Credentials, key ids, emails and the + review demo account are masked. `--md`, `--json`, `--no-open`, `--offline`, `--out`. The desktop's Checks tab has a + Report button (same code path) and its Markdown export uses the same renderer; MCP gets a read-only `report` tool. + - **Remote config and remote paywalls (App Review 2.3.1, Play Deceptive behavior).** `storebound scan` (and `validate`; `check` as a warning) reports `remote-features` for both stores when the app ships a remote config / feature flag SDK (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags, diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index d8c608a..a03dc88 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -174,9 +174,14 @@ pub fn project_relative(rel: &str) -> Option { Some(p.to_path_buf()) } -/// What `project_reveal` may open (not only show in the file manager): a folder, or the gallery page storebound writes. +/// What `project_reveal` may open (not only show in the file manager): a folder, the gallery page storebound writes, +/// or a readiness report page of `storebound report` (.storebound/reports/readiness-*.html, no scripts in it). pub fn openable(rel: &std::path::Path, is_dir: bool) -> bool { - is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") + if is_dir || rel.file_name().and_then(|n| n.to_str()) == Some("index.html") { + return true; + } + let parts: Vec<&str> = rel.components().filter_map(|c| c.as_os_str().to_str()).collect(); + matches!(parts.as_slice(), [".storebound", "reports", name] if name.starts_with("readiness-") && name.ends_with(".html") && name.len() <= 80) } /// Hosts `open_external` may open in the browser: the two store consoles and storebound's own site. diff --git a/apps/desktop/src-tauri/tests/complete.rs b/apps/desktop/src-tauri/tests/complete.rs index 798002e..e894dbf 100644 --- a/apps/desktop/src-tauri/tests/complete.rs +++ b/apps/desktop/src-tauri/tests/complete.rs @@ -32,6 +32,10 @@ fn app_folder_paths() { assert!(openable(Path::new("store/renders/index.html"), false)); assert!(openable(Path::new("store/renders"), true)); assert!(!openable(Path::new("store/renders/run.sh"), false)); + assert!(openable(Path::new(".storebound/reports/readiness-2026-10-18.html"), false)); + assert!(!openable(Path::new(".storebound/reports/readiness-2026-10-18.sh"), false)); + assert!(!openable(Path::new(".storebound/credentials.json"), false)); + assert!(!openable(Path::new("other/reports/readiness-2026-10-18.html"), false)); assert!(!openable(Path::new("store/prices.csv"), false)); } diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index ecf2f05..1189868 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -5,6 +5,7 @@ // not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER). import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult, type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api'; +import { buildReport } from '../../../../cli/lib/report'; import { buildApps, type Overrides } from '../lib/identity'; import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model'; import { editableFixture, liveFixture } from './editor-fixtures'; @@ -461,6 +462,12 @@ function read(method: string, id: string, args: Record = {}): T case 'apple.testflight_status': return ok(method, TF[id]); case 'catalog.diff': return ok(method, CATALOG[id]); case 'validate': return ok(method, VALIDATE[id]); + case 'report': { + const sum = SUMMARY[id]; + const r = buildReport({ app: { name: sum?.name ?? id }, stores: (['apple', 'play'] as const).filter(x => !!sum?.[x]), storeRead: { apple: true, play: 'no Play service account' }, + generatedAt: new Date().toISOString(), storebound: 'fixture', validate: VALIDATE[id] as never, scan: (SCAN[id] ?? SCAN_EMPTY) as never }); + return ok(method, { file: `/fixture/${id}/.storebound/reports/readiness.html`, path: '.storebound/reports/readiness.html', ...r }); + } case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY); case 'project.declarations': return ok(method, DECLARATIONS[id]); case 'project.listing_text': return ok(method, LISTING_TEXT[id]); @@ -505,7 +512,7 @@ const CONSOLE_FILL: Record = { }; const CACHED_AGO: Record = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 }; -const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']); +const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']); async function progressLines(job: string | null, lines: string[]) { for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 730adf0..58c20e5 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -422,6 +422,14 @@ export const en = { 'checks.problem': 'Problem', 'checks.warning': 'Warning', 'checks.validate': 'Store readiness', + 'checks.report': 'Shareable report', + 'checks.reportSub': 'One page for both stores with every finding, fix and console link, and the questions only you can answer. Opens in your browser; nothing is sent to a store.', + 'checks.reportOpen': 'Report', + 'checks.reportFailed': 'The report was not written', + 'checks.reportBlockers': '{count} blocker(s)', + 'checks.reportNotChecked': 'not checked (no store key)', + 'checks.reportNotSetUp': 'not set up', + 'checks.reportReady': 'ready to submit', 'checks.validateSub': 'Read-only report from both stores, blockers in order', 'checks.run': 'Run the report', 'checks.validateEmpty': 'Readiness not checked yet', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index aca173a..a660602 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -424,6 +424,14 @@ export const tr: Record = { 'checks.problem': 'Sorun', 'checks.warning': 'Uyarı', 'checks.validate': 'Mağaza hazırlığı', + 'checks.report': 'Paylaşılabilir rapor', + 'checks.reportSub': 'İki mağaza için tek sayfa: her bulgu, düzeltme ve konsol bağlantısı, yalnızca senin yanıtlayabileceğin sorular. Tarayıcında açılır; mağazaya hiçbir şey gönderilmez.', + 'checks.reportOpen': 'Rapor', + 'checks.reportFailed': 'Rapor yazılamadı', + 'checks.reportBlockers': '{count} engel', + 'checks.reportNotChecked': 'kontrol edilmedi (mağaza anahtarı yok)', + 'checks.reportNotSetUp': 'kurulmamış', + 'checks.reportReady': 'gönderime hazır', 'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla', 'checks.run': 'Raporu çalıştır', 'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi', diff --git a/apps/desktop/src/lib/exports.ts b/apps/desktop/src/lib/exports.ts index d918dfb..e4a7430 100644 --- a/apps/desktop/src/lib/exports.ts +++ b/apps/desktop/src/lib/exports.ts @@ -4,6 +4,7 @@ import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api'; import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model'; import { isError } from './model'; +import { buildReport, renderMarkdown } from '../../../../cli/lib/report'; type Cell = string | number | boolean | null | undefined; @@ -62,11 +63,12 @@ export function healthMd(app: string, h: HealthSnapshot): string { return out.join('\n'); } -export function validateMd(app: string, v: ValidateResult, at: string | null): string { - const lvl = (l: string) => (l === 'blocker' ? 'Blocker' : l === 'warn' ? 'Warning' : 'OK'); - return [`# ${app}: store readiness`, '', `Read ${at ?? 'now'} · ${v.blockers} blocker(s), ${v.findings.filter(f => f.level === 'warn').length} warning(s)`, '', - mdTable(['store', 'level', 'check', 'finding', 'fix'], v.findings.map(f => [f.store === 'apple' ? 'App Store' : 'Google Play', lvl(f.level), f.code, f.msg, f.fix ?? ''])), - ].join('\n'); +/** The store readiness export: the same report and Markdown renderer as `storebound report --md` (cli/lib/report.ts), + * from what the Checks tab read (validate, the offline check, the scan when it ran). */ +export function validateMd(app: string, v: ValidateResult, at: string | null, more: { check?: { problems: string[]; warnings: string[] } | null; scan?: unknown; version?: string; stores?: ('apple' | 'play')[] } = {}): string { + const r = buildReport({ app: { name: app }, ...(more.stores ? { stores: more.stores } : {}), generatedAt: at ?? new Date().toISOString(), storebound: more.version ?? 'desktop', + validate: v as never, check: more.check ?? null, scan: (more.scan ?? null) as never, storeRead: { apple: true, play: true } }); + return renderMarkdown(r); } export function catalogMd(app: string, c: CatalogDiff): string { diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index 903880d..1752997 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -7,7 +7,9 @@ import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, Validat import { useData, useRead, useWrite } from '../../store'; import { WriteButton } from '../../ui/WriteButton'; import { AiFix, aiFinding } from '../../ui/AiFix'; -import { api } from '../../api'; +import { api, errorText, newJob } from '../../api'; +import type { ToolResult } from '../../api'; +import type { ReportData } from '../../../../../cli/lib/report'; import { useEffect, useState } from 'react'; import { Icon } from '../../ui/icons'; import { OpenLink } from '../../ui/OpenLink'; @@ -15,6 +17,8 @@ import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/ import { exportName, validateMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; +type ReportResult = ReportData & { file: string; path: string }; + const LEVEL: Record = { blocker: ['bad', 'alertCircle'], warn: ['warn', 'alert'], ok: ['ok', 'check'] }; /** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store @@ -93,18 +97,55 @@ function ScanCard({ project }: { project: Project }) { ); } +/** The shareable readiness report (`storebound report`, the same code path as the CLI): one HTML page in + * .storebound/reports/, opened in the default browser through project_reveal (the host only opens that page). */ +function ReportBar({ project }: { project: Project }) { + const d = useData(); + const [busy, setBusy] = useState(false); + const [last, setLast] = useState(null); + const run = async () => { + setBusy(true); + try { + const r = await api.call('report', project.id, {}, newJob('report')); + if (!r.ok || !r.data?.path) throw new Error(r.output || t('checks.reportFailed')); + setLast(r.data); + await api.reveal(project.id, r.data.path, true); + } catch (e) { d.toast({ kind: 'bad', title: t('checks.reportFailed'), detail: errorText(e) }); } + finally { setBusy(false); } + }; + return ( + {t('checks.reportOpen')}}> + {null} + + ); +} + +/** One line, both stores: "App Store: 2 blockers · Google Play: not checked". */ +export function reportSummary(r: ReportResult): string { + const one = (s: 'apple' | 'play') => { + const x = r.summary[s], read = r.read[s]; + const v = x.blockers ? t('checks.reportBlockers', { count: x.blockers }) : !r.stores.includes(s) ? t('checks.reportNotSetUp') : !read.checked ? t('checks.reportNotChecked') : t('checks.reportReady'); + return `${s === 'apple' ? 'App Store' : 'Google Play'}: ${v}`; + }; + return `${one('apple')} · ${one('play')}`; +} + export function ChecksTab({ project }: { project: Project }) { const d = useData(); const e = d.statusOf(project); const v = useRead(project.id, 'validate', { network: true, title: t('checks.validate') }); const w = useWrite(project.id); const name = e?.summary?.name ?? project.name; - useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'), build: () => validateMd(name, v.data!, v.at) }]); + const stores = (['apple', 'play'] as const).filter(x => !!e?.summary?.[x]); + useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'), + build: () => validateMd(name, v.data!, v.at, { check: e?.check ?? null, version: d.info?.version, ...(stores.length ? { stores } : {}) }) }]); const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])]; const warnings = e?.check?.warnings ?? []; const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level)); return (
+
d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}>{t('checks.again')}}> diff --git a/cli/commands.ts b/cli/commands.ts index 9918485..151c217 100644 --- a/cli/commands.ts +++ b/cli/commands.ts @@ -29,6 +29,7 @@ export const COMMANDS: Record Promise, 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-.html, opened in the browser; --out , --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 --to : one image of the app folder'], diff --git a/cli/data/flags.json b/cli/data/flags.json index b9cf5c7..467de14 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -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 ] [--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]", diff --git a/cli/lib/report.ts b/cli/lib/report.ts new file mode 100644 index 0000000..d713dd6 --- /dev/null +++ b/cli/lib/report.ts @@ -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; + summary: Record; + 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>; + 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>; + /** 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 = { apple: 'App Store', play: 'Google Play', both: 'Both stores' }; +const LEVEL_LABEL: Record = { 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(); + 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; + 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 = { + 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 = (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, '''); + +/** 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 = { 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 && `${e(f.guideline)}`, `${e(f.rule)}`].filter(Boolean).join(''); + return `
${PILL[f.level]}${tags}
` + + `

${e(f.title)}

` + + (f.evidence.length ? `
${f.evidence.map(x => `${e(x)}`).join('')}
` : '') + + (f.fix ? `

${e(f.fix)}

` : '') + + (f.url ? `Open in ${consoleName(f.url)}` : '') + + '
'; +} + +/** 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 `

${STORE_LABEL[s]}

${e(v.big)}
` + + `
${x.blockers} blockers${x.warnings} warnings${x.ok} passed
` + + (v.sub ? `
${e(v.sub)}
` : '') + '
'; + }; + 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 = `

${STORE_LABEL[s]}

${open.length ? `${open.length} to fix, most severe first` : 'Nothing to fix'}
`; + const fold = (label: string, list: ReportFinding[]) => (list.length ? `
${list.length} ${label}${list.map(findingHtml).join('')}
` : ''); + const empty = s !== 'both' && !r.stores.includes(s) ? `Not set up: ${r.read[s].why ?? 'not in the config'}.` : 'No blockers or warnings.'; + return `
${head}${open.map(findingHtml).join('') || `
${e(empty)}
`}${fold(notes.length === 1 ? 'note' : 'notes', notes)}${fold('passed', ok)}
`; + }; + 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) ? `` : ''; + 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 `

${STORE_LABEL[st]}

${list.length + ? `` + : `
${none}
`}
`; + }; + const q = `
${(['apple', 'play', 'both'] as const).map(qList).join('')}
`; + return ` + + +${e(r.app.name)}: store readiness +
+
${icon}

${e(r.app.name)}

${e(meta)}
+
${cols.map(verdict).join('')}
+${both} +
${stores.map(section).join('')}
+

Questions only you can answer

Compliance facts no code or config can prove. Answer them once; storebound writes them into the config.
${q}
+
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.
+
+`; +} + +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(' · '); +} diff --git a/cli/mcp-tools.ts b/cli/mcp-tools.ts index 8d42d58..318c5eb 100644 --- a/cli/mcp-tools.ts +++ b/cli/mcp-tools.ts @@ -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-.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 } }, diff --git a/cli/report.ts b/cli/report.ts new file mode 100644 index 0000000..7a4b6e0 --- /dev/null +++ b/cli/report.ts @@ -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 ] [--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`); +} diff --git a/cli/serve.ts b/cli/serve.ts index d262a97..ee05729 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -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']); diff --git a/docs/commands.md b/docs/commands.md index 8dc72c1..4c00820 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -20,6 +20,7 @@ run in the background with a log (`storebound shots render > .storebound/render. | `check --aso` | local | keyword audit: App Store keyword field rules, Play stuffing / claims | | `scan [--store apple\|play] [--json] [--write] [--originality --against ]` | local, read-only | rejection risks in the project itself (stack, SDKs, Info.plist, manifest, listings) with guideline, evidence file:line, fix and skill; Data safety / App Privacy / ATT / privacy manifest proposal as a diff (`--write` adds the detected answers to the config); exit 1 on blockers | | `validate [--apple] [--play]` | API, read-only | readiness report with ordered blockers plus the local renders checks (`--no-local` skips); exit 1 while any blocker is left | +| `report [--out ] [--md] [--json] [--no-open] [--offline]` | local + API, read-only | shareable store readiness report: `scan`, `check` and `validate` (only the stores whose credentials are there; otherwise each store says "Not checked" and why) in one self-contained HTML page `.storebound/reports/readiness-.html` (inline CSS, no scripts, light / dark, print-friendly): a verdict per store, findings by store and severity with guideline, evidence, fix and console link, the questions only you can answer. Opens in the browser unless `--no-open`, `--json`, CI or no terminal; credentials, key ids, emails and the demo account are masked. `--md` writes Markdown, `--json` prints the data with the file path. Desktop: Checks > Report; MCP: `report` | | `originality --against [--json]` | local, read-only | Guideline 4.3 check vs your other apps: name, listing text, icon and screenshot perceptual hashes, project structure; scores, near-duplicate flags, suggestions; exit 1 when flagged | | `whats-new [--since ]` | local | draft player-facing release notes from git log into both listing files | | `whats-new --languages en,de,tr` / `check` / `apply` | local | one file per language (`store/whats-new/`) for your agent (or a `--translate` hook of yours) to translate, per-store limits (Play 500, App Store 4000), then into both listing files | diff --git a/docs/mcp.md b/docs/mcp.md index a54246f..0e8b66c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5,7 +5,7 @@ per-request `_meta`, `resultType`, cacheable `tools/list`) and, on the same proc `initialize` (2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05). It runs each tool as a CLI call in the project directory (the server's working directory, or `STOREBOUND_PROJECT`) and returns the output. -- One tool per command. Read-only: `scan`, `check`, `check_aso`, `aso_competitors` (no snapshot saved), `aso_suggest` (never writes), `aso_audit`, `validate`, `catalog_diff`, `health`, `reviews_list`, `prices_diff`, +- One tool per command. Read-only: `scan`, `check`, `check_aso`, `aso_competitors` (no snapshot saved), `aso_suggest` (never writes), `aso_audit`, `validate`, `report` (writes only the report page in .storebound/reports/ and returns its path and both stores' verdicts), `catalog_diff`, `health`, `reviews_list`, `prices_diff`, `analytics_compare`, `apple_status`, `apple_next_build`, `apple_testflight_status`, `apple_ppo_status`, `play_status`, `play_next_version_code`, `privacy_manifest`, `shots_check`, `workspace_list`, `workspace_status`, `release_status`, `release_plan`, `flow_list`, `flow_run` (dry mode only), `listing_check`, `api_get` (any App Store diff --git a/skills/store-preflight/SKILL.md b/skills/store-preflight/SKILL.md index 95d6ba3..8d043d4 100644 --- a/skills/store-preflight/SKILL.md +++ b/skills/store-preflight/SKILL.md @@ -40,6 +40,14 @@ Many apps (a `storebound.workspace.json` above the app folders, `storebound ws i and `storebound ws validate [--apps a,b]` run the same checks over every app, a few at a time, each with its studio's credential profile; `storebound ws run --apps a,b` runs any other read command (writes only as dry runs). +## Share the report: `storebound report` + +For the owner, a teammate or a client who should not need a terminal: `storebound report` runs `scan`, `check` and +`validate` (read-only; a store without credentials shows "Not checked" with the reason) and writes one self-contained +page, `.storebound/reports/readiness-.html`, then opens it in the browser. A verdict per store, every finding +with its fix and console link, and the questions only the owner can answer. Secrets are masked. `--md` for Markdown, +`--json` for the data, `--no-open` in scripts. MCP: `report`; desktop: Checks > Report. + ## Reading `scan` | finding | fix | skill | |---|---|---| diff --git a/tests/report.test.ts b/tests/report.test.ts new file mode 100644 index 0000000..70f8579 --- /dev/null +++ b/tests/report.test.ts @@ -0,0 +1,150 @@ +// storebound report: the shared renderer (grouping, order, links, escaping, masking) and the command (file written, +// --no-open, --md, --json, validate skipped without credentials, no fixture secret in any output). +import { describe, expect, test } from 'bun:test'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildReport, maskSecrets, renderHtml, renderMarkdown, summaryText } from '../cli/lib/report'; +import { shouldOpen } from '../cli/report'; + +const ROOT = join(import.meta.dir, '..'); +const PEM = '-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgFIXTURE000000000000\n-----END PRIVATE KEY-----'; +const SECRETS = { keyId: 'K2X9Q7W4Z1', issuer: '6f1c2a3b-4d5e-4f60-8a9b-0c1d2e3f4a5b', demoUser: 'reviewer.fixture@corp.invalid', demoPass: 'Fixture-Pass-9931', contact: 'owner.fixture@corp.invalid' }; + +const fixture = () => buildReport({ + app: { name: 'Fixture ', version: '1.2.0', build: '42' }, stores: ['apple', 'play'], storeRead: { apple: true, play: 'no Play service account' }, + generatedAt: '2026-10-18T10:00:00Z', storebound: '0.0.0-test', secrets: [SECRETS.demoPass], + scan: { + findings: [ + { rule: 'att', guideline: '5.1.2', store: 'apple', severity: 'medium', title: 'Tracking without ATT', evidence: [{ file: 'ios/App/Info.plist', line: 12 }], fix: 'Add the ATT prompt', skill: 'privacy-manifest' }, + { rule: 'demo-account', guideline: '2.1', store: 'apple', severity: 'blocker', title: 'No demo account ', evidence: [], fix: `Use ${SECRETS.demoUser} / ${SECRETS.demoPass}`, url: 'https://appstoreconnect.apple.com/apps/123/distribution/reviewsubmissions' }, + { rule: 'privacy-labels', guideline: 'Data safety', store: 'play', severity: 'high', title: 'Labels miss DEVICE_ID', evidence: [{ file: 'android/app/build.gradle', line: 30 }], fix: 'Declare it', url: 'javascript:alert(1)' }, + { rule: 'privacy-labels', guideline: 'Data safety', store: 'apple', severity: 'high', title: 'Labels miss DEVICE_ID', evidence: [{ file: 'android/app/build.gradle', line: 30 }], fix: 'Declare it' }, + { rule: 'accepted-one', guideline: 'x', store: 'play', severity: 'blocker', title: 'Accepted by the owner', evidence: [], fix: 'none', accepted: 'fine' }, + { rule: 'obfuscator', guideline: '2.3.1', store: 'play', severity: 'low', title: 'Obfuscator found', evidence: [], fix: 'Mention it' }, + ], + declarations: { items: [{ area: 'dataSafety', what: 'encryptedInTransit', value: '?', status: 'ask' }, { area: 'dataSafety', what: 'PSL_NAME', value: 'yes', status: 'detected' }] }, + }, + check: { ok: false, problems: [`key ${SECRETS.keyId} issuer ${SECRETS.issuer} in ${SECRETS.contact}`], warnings: ['play listing has no section for de-DE'], hints: [], items: [{ what: 'appstore listing', detail: '1 locales' }] }, + validate: { blockers: 1, findings: [{ store: 'apple', level: 'blocker', code: 'build', msg: 'No build attached', fix: 'storebound apple upload' }, { store: 'apple', level: 'ok', code: 'name', msg: 'Name set' }] }, +}); + +describe('report renderer', () => { + test('groups by store, merges both stores, orders by severity, drops accepted findings', () => { + const r = fixture(); + const both = r.findings.filter(f => f.store === 'both'); + expect(both.map(f => f.title)).toContain('Labels miss DEVICE_ID'); + expect(r.findings.some(f => f.title === 'Accepted by the owner')).toBe(false); + const apple = r.findings.filter(f => f.store === 'apple').map(f => f.level); + expect(apple).toEqual([...apple].sort((a, b) => ['blocker', 'warn', 'info', 'ok'].indexOf(a) - ['blocker', 'warn', 'info', 'ok'].indexOf(b))); + expect(r.findings[0]!.level).toBe('blocker'); + expect(r.summary.apple.blockers).toBe(2); + expect(r.summary.play.warnings).toBe(1); + expect(r.questions.map(q => q.text)).toContain('encryptedInTransit: ?'); + expect(summaryText(r)).toContain('App Store: 2 blockers'); + expect(summaryText(r)).toContain('Google Play: Not checked (no credentials)'); + }); + + test('html: both store columns, console links, evidence, no script, escaped text, unsafe urls dropped', () => { + const html = renderHtml(fixture()); + expect(html).toContain('

App Store

'); + expect(html).toContain('

Google Play

'); + expect(html).toContain('href="https://appstoreconnect.apple.com/apps/123/distribution/reviewsubmissions"'); + expect(html).toContain('Open in App Store Connect'); + expect(html).toContain('ios/App/Info.plist:12'); + expect(html).toContain('5.1.2'); + expect(html).toContain('Questions only you can answer'); + expect(html).toContain('prefers-color-scheme:dark'); + expect(html).toContain('@media print'); + expect(html).not.toContain('