desktop: a Fix button on scan and validate findings storebound can fix itself. Findings carry action { method, args } (cli/lib/fix-actions.ts); privacy-labels runs scan.write (scan --write-only, the dialog shows the config diff), Play listing gaps play.listing, a missing App Store localization or copyright apple.listing (editable version only), the App Review contact apple.review_details; each a dry run, the native dialog, then the confirmed run. --write-only is forbidden for MCP tools; tests pin the mapping, the round trip on a fixture and that no fix reaches a submit, release or publish

This commit is contained in:
CRTkafa 2026-10-15 06:14:49 +08:00
parent bd83d036ba
commit 992f5bcc0e
18 changed files with 257 additions and 37 deletions

View file

@ -1,6 +1,7 @@
// Offline, read-only scan of the project itself for App Store and Google Play rejection risks, before you submit.
// storebound scan [--dir <project>] [--config <file>] [--store apple|play] [--json] [--no-declarations]
// storebound scan --write also write the detected dataSafety / appPrivacy answers into the config
// storebound scan --write-only [--dry] only that write (with --dry: the config diff), no report (desktop Fix button)
// storebound scan --originality [--against <folders>] [--threshold 0.7] also the 4.3 / Play repetitive content
// check against your other apps (storebound originality; folders default to originality.against in the config)
// Reads the project files (Xcode project, Info.plist, entitlements, privacy manifests, Podfile.lock, Package.resolved,
@ -16,7 +17,8 @@ import type { Args } from './lib/args';
import { CONFIG_NAMES } from './lib/config';
import { log, printJson, warn } from './lib/log';
import { upsertBlock } from './lib/scan/config-edit';
import { addOriginality, formatScan, runScan } from './lib/scan';
import { addOriginality, formatScan, runScan, type ScanReport } from './lib/scan';
import { compactDiff } from './lib/diff';
import { compareAgainst } from './originality';
/** Project folder: --dir, else the folder of the nearest storebound.config.* above the working directory, else it. */
@ -48,25 +50,43 @@ export async function run(args: Args): Promise<void> {
report.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
for (const f of report.findings) { if (f.accepted) report.counts.accepted++; else report.counts[f.severity]++; }
} else if (store) throw new Error('--store apple | play');
if (args.has('json')) printJson(report);
else console.log(formatScan(report, { declarations: !args.has('no-declarations') }));
// --write-only (the desktop's Fix button, scan.write): only the write and its diff, no report, no blocker exit
const writeOnly = args.has('write-only');
if (!writeOnly) {
if (args.has('json')) printJson(report);
else console.log(formatScan(report, { declarations: !args.has('no-declarations') }));
}
if (args.has('write')) {
if (args.has('write') || writeOnly) {
const d = report.declarations;
if (!configPath) warn('scan', '--write: no storebound.config.ts in this project (storebound init creates one)');
else if (!d.changed) log('scan', `--write: nothing to change in ${report.config}`);
else if (args.has('dry')) log('scan', `--dry: would write ${[(d.diff.some(x => x.change !== 'kept' && x.change !== 'same') || d.accountCreationChange) && 'dataSafety', (d.appPrivacyChange || d.appPrivacyTypes) && 'appPrivacy'].filter(Boolean).join(' + ')} into ${report.config}`);
if (!configPath) {
if (writeOnly) throw new Error('no storebound.config.ts in this project (storebound init creates one)');
warn('scan', '--write: no storebound.config.ts in this project (storebound init creates one)');
} else if (!d.changed) log('scan', `--write: nothing to change in ${report.config}`);
else {
let text = readFileSync(configPath, 'utf8');
const ds = d.dataSafety!;
const missing = (['encryptedInTransit'] as const).filter(k => typeof ds[k] !== 'boolean');
if (d.diff.some(x => x.change === 'add' || x.change === 'merge') || d.accountCreationChange)
text = upsertBlock(text, configPath, 'dataSafety', ds, missing.length ? `storebound scan: answer ${missing.join(' and ')} (ask the owner; store-declarations skill)` : undefined);
if (d.appPrivacyChange || d.appPrivacyTypes) text = upsertBlock(text, configPath, 'appPrivacy', d.appPrivacy,
d.appPrivacyTypes ? 'storebound scan: types proposed from the detected SDKs; answer linked / tracking per type (ask the owner; store-declarations skill)' : undefined);
writeFileSync(configPath, text);
log('scan', `wrote the detected answers into ${report.config}${missing.length ? `; still to answer: dataSafety.${missing.join(', dataSafety.')}` : ''}. Review the diff (git diff) before pushing it to a store.`);
const before = readFileSync(configPath, 'utf8');
const { text, missing } = declarationsWrite(before, configPath, d);
const what = [(d.diff.some(x => x.change !== 'kept' && x.change !== 'same') || d.accountCreationChange) && 'dataSafety', (d.appPrivacyChange || d.appPrivacyTypes) && 'appPrivacy'].filter(Boolean).join(' + ');
if (args.has('dry')) {
log('scan', `--dry: would write ${what} into ${report.config}`);
console.log([`--- ${report.config}`, ...compactDiff(before, text)].join('\n'));
} else {
writeFileSync(configPath, text);
log('scan', `wrote the detected answers into ${report.config}${missing.length ? `; still to answer: dataSafety.${missing.join(', dataSafety.')}` : ''}. Review the diff (git diff) before pushing it to a store.`);
}
}
}
if (report.counts.blocker) process.exitCode = 1;
if (report.counts.blocker && !writeOnly) process.exitCode = 1;
}
/** The config text with the detected answers written in (dataSafety / appPrivacy blocks; comments and order kept). */
export function declarationsWrite(before: string, configPath: string, d: ScanReport['declarations']): { text: string; missing: string[] } {
let text = before;
const ds = d.dataSafety!;
const missing = (['encryptedInTransit'] as const).filter(k => typeof ds[k] !== 'boolean');
if (d.diff.some(x => x.change === 'add' || x.change === 'merge') || d.accountCreationChange)
text = upsertBlock(text, configPath, 'dataSafety', ds, missing.length ? `storebound scan: answer ${missing.join(' and ')} (ask the owner; store-declarations skill)` : undefined);
if (d.appPrivacyChange || d.appPrivacyTypes) text = upsertBlock(text, configPath, 'appPrivacy', d.appPrivacy,
d.appPrivacyTypes ? 'storebound scan: types proposed from the detected SDKs; answer linked / tracking per type (ask the owner; store-declarations skill)' : undefined);
return { text, missing };
}