// Offline, read-only scan of the project itself for App Store and Google Play rejection risks, before you submit. // storebound scan [--dir ] [--config ] [--store apple|play] [--json] [--no-declarations] // storebound scan --write also write the detected dataSafety / appPrivacy answers into the config // storebound scan --originality [--against ] [--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, // Gradle files, AndroidManifest.xml and a local build's merged manifest, res/xml and strings.xml, CMake / ndk-build // files, package.json, app.json, capacitor.config, pubspec, Unity and Godot settings and ProjectVersion.txt), the // config and the listing files; detects the stack and the SDKs; reports each risk with the guideline, the evidence // (file:line), the fix, a severity and the skill; and proposes the Data safety / App Privacy / ATT / privacy manifest // answers as a diff against the config. Nothing is written without --write (and --dry always wins). No network. // Exit 1 while an open blocker is left. import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; 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 { compareAgainst } from './originality'; /** Project folder: --dir, else the folder of the nearest storebound.config.* above the working directory, else it. */ function projectDir(args: Args): string { if (args.get('dir')) return resolve(args.get('dir')!); for (let d = process.cwd(); ; d = dirname(d)) { if (CONFIG_NAMES.some(n => existsSync(join(d, n)))) return d; if (dirname(d) === d) return process.cwd(); } } export async function run(args: Args): Promise { const dir = projectDir(args); if (!existsSync(dir)) throw new Error(`${dir} does not exist`); const { report, configPath, config } = await runScan(dir, { config: args.get('config') }); if (args.has('originality')) { if (!config || !configPath) report.originality = { error: 'no storebound.config.ts loaded in this project' }; else { const base = dirname(configPath); try { const { report: o } = await compareAgainst({ config, root: base, resolve: p => resolve(base, p) }, args.list('against'), args.has('threshold') ? args.num('threshold', 0.7) : undefined, m => warn('scan', m)); addOriginality(report, o, { apple: !!config.apple, play: !!config.play }, config, p => relative(dir, p)); } catch (e) { report.originality = { error: (e as Error).message }; } } } const store = args.get('store'); if (store === 'apple' || store === 'play') { report.findings = report.findings.filter(f => f.store === store); 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') })); if (args.has('write')) { 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}`); else { let text = readFileSync(configPath, 'utf8'); const ds = d.dataSafety!; const missing = (['encryptedInTransit', 'deletionRequest'] 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.`); } } if (report.counts.blocker) process.exitCode = 1; }