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:
CRTkafa 2026-10-18 01:42:03 +08:00
parent 6d95ea00f5
commit af349b3db1
18 changed files with 792 additions and 12 deletions

View file

@ -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<string, unknown> = {}): 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<ConsoleForm, ConsoleFillResult> = {
};
const CACHED_AGO: Record<string, number> = { 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 }); }

View file

@ -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',

View file

@ -424,6 +424,14 @@ export const tr: Record<keyof typeof en, string> = {
'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',

View file

@ -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 {

View file

@ -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<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'alert' | 'check']> = { 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<ReportResult | null>(null);
const run = async () => {
setBusy(true);
try {
const r = await api.call<ToolResult & { data?: ReportResult }>('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 (
<Card title={t('checks.report')} icon="external" sub={last ? reportSummary(last) : t('checks.reportSub')}
actions={<button className="btn btn-sm btn-primary" onClick={run} disabled={busy}><Icon name={busy ? 'refresh' : 'external'} className={busy ? 'spin' : undefined} />{t('checks.reportOpen')}</button>}>
{null}
</Card>
);
}
/** 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<ValidateResult>(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 (
<div className="grid g2" style={{ alignItems: 'start' }}>
<div style={{ gridColumn: '1 / -1' }}><ReportBar project={project} /></div>
<div style={{ gridColumn: '1 / -1' }}><ScanCard project={project} /></div>
<Card title={t('checks.offline')} icon="shield" sub={t('checks.offlineSub')} pad={false}
actions={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>}>