storebound/cli/ads.ts
CRT d972c36fa8 Merge store-ops: TestFlight wait / expire, IAP review submissions, pulls, offers, CPP, events, reports, triage, ASO rank, ads scan
Conflicts resolved for both sides:
- cli/lib/plist.ts, cli/lib/zip.ts: one implementation each. plist reads XML (text or
  bytes, attributes tolerated) and binary plists; ads scan now reads bytes, so a binary
  Info.plist works too. zip gains unzip() for the Play report exports on top of
  zipEntries / zipRead.
- apple upload waits with testflight wait's loop (waitForBuild) instead of its own copy;
  BUILD_DONE removed.
- config, credentials (profiles, keystore, ASC_VENDOR_NUMBER, now also a profile key),
  command table, MCP tools, credentials template, README, CHANGELOG: union.
- MCP: --write stays forbidden; the three pull tools are the listed local-file exception
  (LOCAL_WRITE_TOOLS), so scan --write remains unreachable.
- Adapters and API coverage tables regenerated.
2026-10-12 00:07:26 +08:00

71 lines
4.8 KiB
TypeScript

// Local AdAttributionKit / SKAdNetwork scan of an iOS project (no network, no credentials, no config needed).
// storebound ads scan --ios ios/App [--plist path/to/Info.plist] [--dry] [--json]
// Reads every app Info.plist (Pods, build output, tests and frameworks left out) plus Podfile.lock / Package.resolved
// and the Swift / Objective-C sources, then checks: ad network ids (SKAdNetworkItems, AdNetworkIdentifiers;
// lowercase, format, ad SDKs without ids), postback copy endpoints (AttributionCopyEndpoint, the re-engagement opt-in,
// NSAdvertisingAttributionReportEndpoint), AdAttributionKitConfigurations (attribution windows, cooldown) and that an
// advertised app updates its conversion value. Exit 1 on a blocker. --dry is accepted and changes nothing (read-only).
// Rules and Apple's documentation links: cli/lib/adattribution.ts; the ads-mediation-checks skill explains the fixes.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import type { Args } from './lib/args';
import { type AakFinding, evaluateAdAttribution, sourceSignals } from './lib/adattribution';
import { done, printJson } from './lib/log';
import { parsePlist, type PlistValue } from './lib/plist';
const SKIP = new Set(['.git', 'build', 'DerivedData', 'node_modules', '.build', 'xcuserdata', 'Pods', 'Carthage', 'fastlane']);
const SKIP_PLIST = /(Tests?|UITests|\.bundle|\.framework|\.xcframework|\.appex|Extension|Widget|Watch)(\/|$)/i;
function walk(dir: string, visit: (p: string) => void, depth = 0): void {
if (depth > 12) return;
let names: string[] = [];
try { names = readdirSync(dir); } catch { return; }
for (const n of names) {
if (SKIP.has(n)) continue;
const p = join(dir, n);
let st;
try { st = statSync(p); } catch { continue; }
if (st.isDirectory()) walk(p, visit, depth + 1); else visit(p);
}
}
export async function run(args: Args): Promise<void> {
if (args._[0] !== 'scan') throw new Error('usage: storebound ads scan --ios <path to the iOS project folder, e.g. ios/App> [--plist Info.plist] [--json]');
const ios = args.get('ios');
if (!ios) throw new Error('--ios <path to the iOS project folder>');
const root = resolve(ios);
if (!existsSync(root)) throw new Error(`${root} does not exist`);
const plists: string[] = [];
const files: { path: string; text: string }[] = [];
walk(root, p => {
const rel = relative(root, p);
if (p.endsWith('/Info.plist') && !SKIP_PLIST.test(rel)) plists.push(p);
if (/(\.swift|\.m|\.mm|Podfile\.lock|Package\.resolved|Cartfile\.resolved|project\.pbxproj)$/.test(p)) {
try { if (statSync(p).size < 4_000_000) files.push({ path: rel, text: readFileSync(p, 'utf8') }); } catch { /* unreadable */ }
}
});
// lock files usually sit next to the project folder (ios/Podfile.lock for ios/App)
for (const extra of ['Podfile.lock', '../Podfile.lock', 'Package.resolved', '../Package.resolved']) {
const p = join(root, extra);
if (existsSync(p) && !files.some(f => resolve(root, f.path) === resolve(p))) files.push({ path: extra, text: readFileSync(p, 'utf8') });
}
const chosen = args.get('plist') ? [resolve(args.get('plist')!)] : plists;
const signals = sourceSignals(files);
const results: { plist: string; findings: AakFinding[] }[] = [];
for (const p of chosen) {
let parsed: PlistValue;
try { parsed = parsePlist(readFileSync(p)); } catch (e) { results.push({ plist: relative(process.cwd(), p), findings: [{ level: 'warn', code: 'plist-read', msg: (e as Error).message }] }); continue; }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Buffer.isBuffer(parsed)) continue;
results.push({ plist: relative(process.cwd(), p), findings: evaluateAdAttribution(parsed, signals) });
}
if (!chosen.length) results.push({ plist: '(none)', findings: [{ level: signals.adSdks.length ? 'blocker' : 'info', code: 'no-plist', msg: `no app Info.plist under ${root}${signals.adSdks.length ? ` but ad SDKs (${signals.adSdks.join(', ')}) need SKAdNetworkItems / AdNetworkIdentifiers in one` : ''}` }] });
const blockers = results.flatMap(r => r.findings).filter(f => f.level === 'blocker').length;
if (args.has('json')) printJson({ ios: root, signals, results, blockers });
else {
console.log(`ad SDKs: ${signals.adSdks.join(', ') || 'none found'} · attribution SDKs: ${signals.mmpSdks.join(', ') || 'none'} · import AdAttributionKit: ${signals.aakImport ? 'yes' : 'no'} · conversion value updates in code: ${signals.conversionUpdates ? 'yes' : 'no'}`);
const tag = { blocker: 'BLOCKER', warn: 'warn ', ok: 'ok ', info: 'info ' } as const;
for (const r of results) { console.log(`\n${r.plist}`); for (const f of r.findings) console.log(` ${tag[f.level]} ${f.msg}`); }
}
done('ads', blockers ? `${blockers} blocker(s)` : 'no blocker');
if (blockers) process.exitCode = 1;
}