
Auditing Appstore Readiness
- 87 installs
- 3 repo stars
- Updated June 29, 2026
- tristanmanchester/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
auditing-appstore-readiness is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- auditing-appstore-readiness
- AI & Agent Building
- AI-coding skill
Auditing Appstore Readiness by the numbers
- 87 all-time installs (skills.sh)
- +2 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #4,982 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tristanmanchester/agent-skills --skill auditing-appstore-readinessAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 87 |
|---|---|
| repo stars | ★ 3 |
| Last updated | June 29, 2026 |
| Repository | tristanmanchester/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
App Store Readiness Audit
This skill reviews an app repository and produces a release readiness report for iOS App Store / TestFlight submission.
It supports:
- Native iOS (Swift/Obj‑C, Xcode project/workspace)
- React Native (bare)
- Expo (managed or prebuild)
Quick start (recommended)
Run the read‑only audit script from the repo root:
{ "tool": "exec", "command": "node {baseDir}/scripts/audit.mjs --repo . --format md" }
If you want JSON output as well:
{ "tool": "exec", "command": "node {baseDir}/scripts/audit.mjs --repo . --format md --json audit.json" }
If the repo is a monorepo, point at the app directory:
{ "tool": "exec", "command": "node {baseDir}/scripts/audit.mjs --repo apps/mobile --format md" }
Output contract
Always return:
- Overall verdict: PASS / WARN / FAIL
- Detected project flavour and key identifiers (bundle id, version, build)
- A list of checks with evidence and remediation steps
- A Publish checklist the developer can tick off
Use: references/report-template.md
Safety rules (don’t break the repo)
Default to read‑only commands. Do not run commands that modify the workspace unless:
- the user explicitly asks, or
- the fix is trivial and clearly desired (then explain what will change first)
Examples of mutating commands:
- dependency installs (
npm i,yarn,pnpm i,pod install) - config generation (
expo prebuild) - signing automation (
fastlane match) - archiving (
xcodebuild archive,eas build) — creates artefacts and may require signing
If you must run a mutating command, label it clearly as MUTATING before running.
Main workflow
1) Identify the repo and project flavour
Prefer scripted detection (audit.mjs). If doing manually:
- Expo likely:
package.jsoncontainsexpoandapp.json/app.config.*exists - React Native (bare):
package.jsoncontainsreact-nativeandios/exists - Native iOS:
*.xcodeprojor*.xcworkspaceexists
If multiple apps exist, pick the one matching the user’s intent; otherwise pick the directory with:
- a single
ios/<AppName>/Info.plist, and - exactly one
.xcodeprojor.xcworkspacenear the root.
2) Run static compliance checks (works everywhere)
Run these checks even without Xcode:
- Repo hygiene: clean git status; obvious secrets not committed
- iOS identifiers: bundle id, version, build number
- App icons: includes an App Store (1024×1024) icon
- Launch screen present
- Privacy & permissions:
- Privacy manifest present (
PrivacyInfo.xcprivacy) or explicitly accounted for - Permission usage strings present when relevant (camera, location, tracking, etc.)
- Avoid broad ATS exemptions (
NSAllowsArbitraryLoads) - Third‑party SDK hygiene: licences, privacy manifests, tracking disclosures
- Store listing basics: privacy policy URL exists somewhere in repo/docs; support/contact info
The script outputs PASS/WARN/FAIL for these.
3) Run build‑accuracy checks (macOS + Xcode, optional but high confidence)
Only if you have Xcode available (local macOS gateway or a paired macOS node).
Recommended sequence (creates build artefacts):
1) Show Xcode + SDK versions: { "tool": "exec", "command": "xcodebuild -version" }
2) List schemes (project/workspace as detected): { "tool": "exec", "command": "xcodebuild -list -json -workspace <path>.xcworkspace" } or { "tool": "exec", "command": "xcodebuild -list -json -project <path>.xcodeproj" }
3) Release build for simulator (fast, avoids signing): { "tool": "exec", "command": "xcodebuild -workspace <...> -scheme <...> -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build" }
4) If you need a distribution artefact (MUTATING / signing):
- Prefer Fastlane if already configured
- Otherwise
xcodebuild archive+xcodebuild -exportArchive
If build checks aren’t possible, the report must explicitly say so and keep the verdict at WARN (unless there are definite FAIL items).
4) Produce the final readiness report
- Use references/report-template.md
- Include a “Go / No‑Go” recommendation:
- FAIL → must fix before submitting
- WARN → submission may work, but risk areas remain
- PASS → ready to submit; remaining items are administrative
Manual checks the agent cannot fully verify
Always include these as a final checklist section (even if automated checks pass):
- App Store Connect metadata: screenshots, description, keywords, age rating, pricing, categories
- Privacy Nutrition Labels match actual behaviour
- Export compliance (encryption) answers are correct
- Content/IP rights: licences, third‑party assets, trademarks
- Account / regional requirements (e.g. EU trader status if applicable)
- In‑app purchases / subscriptions configured if used
See: references/manual-checklist.md
When the user asks “make it compliant”
Switch to fix mode: 1) Identify failing items that can be fixed safely in‑repo (Info.plist strings, PrivacyInfo.xcprivacy template, ATS exceptions tightening, etc.) 2) Propose minimal patches and apply with apply_patch 3) Re‑run audit.mjs and update the report
Quick search
- Permissions mapping: references/permissions-map.md
- Expo‑specific checks: references/expo.md
- React Native iOS checks: references/react-native.md
- Native iOS checks: references/native-ios.md
Expo-specific checks (managed / prebuild)
Identify config source
- Prefer
app.json - If using
app.config.js/app.config.ts, treat parsing as best-effort (may be dynamic)
Minimum config for iOS publishing
expo.nameexpo.slugexpo.versionexpo.ios.bundleIdentifierexpo.ios.buildNumber(string)- Icon present (
expo.iconorexpo.ios.icon) and file exists
Common compliance pitfalls
- Missing permission strings when using Expo modules:
- Expo modules often require you to set usage strings via config plugins or
expo.ios.infoPlist. - Tracking:
- If using tracking/ad SDKs or
expo-tracking-transparency, ensureNSUserTrackingUsageDescriptionand ATT flow. - Native modules / prebuild:
expo prebuildand some config plugins modifyios/; treat as MUTATING.
Build pipeline sanity
eas.jsonexists with an iOS profile intended for App Store (oftenproductionorrelease)- Ensure the profile uses
distribution: app-storewhen required - Confirm credentials + signing strategy (EAS credentials vs manual) before “ready to publish”
Suggested commands (may download/install)
npx expo doctor(best-effort)npx expo config --type public(shows resolved config)
Manual checklist (cannot be fully automated)
Use this when preparing the final “ready to submit” recommendation.
App Store Connect listing
- [ ] App name/subtitle match branding guidelines (no trademark violations)
- [ ] Description, keywords, categories, age rating complete
- [ ] Screenshots/video for required devices (iPhone sizes; iPad if supported)
- [ ] App Review notes prepared (test user, demo content, feature flags)
Privacy (high rejection risk)
- [ ] Privacy Policy URL is public and matches behaviour
- [ ] Privacy Nutrition Labels match actual data collection/sharing
- [ ] ATT (tracking prompt) is shown only when required, not gated/incentivised
- [ ] If data is shared with third‑party AI services, disclosures + explicit permission are in place (if applicable)
Legal / licensing
- [ ] Third‑party licences included where required (open source compliance)
- [ ] Rights cleared for fonts, music, images, and user‑generated content flows
- [ ] Terms of Service available if needed
Payments
- [ ] If selling digital goods/services: uses In‑App Purchase where required
- [ ] Subscriptions: configured (group, localisation, introductory offers), and restore flow works
- [ ] “Sign in with Apple” present when required (third‑party social login offered)
Security / fraud
- [ ] No hardcoded credentials, tokens, private keys, or signing assets in the repo
- [ ] TLS / certificate pinning (if any) won’t break App Store review network conditions
Region-specific
- [ ] EU Digital Services Act (DSA) trader status configured if distributing in the EU
- [ ] Any regulated content and local legal disclosures handled
Native iOS (Swift/Obj‑C) checks
Project layout signals
- One
.xcodeprojor.xcworkspacenear the repo root Info.plistfor the main target (oftenSources/.../Info.plistorConfig/...)
High-value compliance checks
- Versioning:
CFBundleShortVersionString(marketing version)CFBundleVersion(build number)- Signing & capabilities:
- Entitlements file exists and matches enabled capabilities
- Push notifications, iCloud, associated domains configured intentionally
- Privacy:
PrivacyInfo.xcprivacypresent and reviewed (Xcode privacy report)- Permission usage strings present for any used capabilities
- App icon + launch screen present and correct
- ATS exemptions avoided or tightly scoped
Suggested commands (may create artefacts)
- List schemes:
xcodebuild -list -json -workspace <App>.xcworkspace(or-project)- Release build for simulator:
xcodebuild -workspace <...> -scheme <...> -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
Permissions → Info.plist usage strings (iOS)
Use this mapping when inferring which NS…UsageDescription keys are required.
Rule: if the app code or dependencies can trigger an iOS permission prompt, the corresponding usage string must exist and be human-readable.
Common mappings
| Capability | Common libs / signals | Required Info.plist keys |
|---|---|---|
| Camera | expo-camera, react-native-camera, vision-camera, AVCaptureDevice | NSCameraUsageDescription |
| Microphone | expo-av, expo-audio, react-native-audio, AVAudioSession | NSMicrophoneUsageDescription |
| Photos (read) | expo-media-library, react-native-image-picker, PHPhotoLibrary | NSPhotoLibraryUsageDescription |
| Photos (add) | photo saving, media export | NSPhotoLibraryAddUsageDescription |
| Location (when in use) | expo-location, @react-native-community/geolocation, CLLocationManager | NSLocationWhenInUseUsageDescription |
| Location (always) | background geofencing/tracking | NSLocationAlwaysAndWhenInUseUsageDescription (and consider review risk) |
| Contacts | expo-contacts, react-native-contacts, CNContactStore | NSContactsUsageDescription |
| Calendars/Reminders | EventKit | NSCalendarsUsageDescription, NSRemindersUsageDescription |
| Bluetooth | BLE libs, CoreBluetooth | NSBluetoothAlwaysUsageDescription |
| Tracking | expo-tracking-transparency, react-native-tracking-transparency, ad/attribution SDKs | NSUserTrackingUsageDescription |
| Face ID | LocalAuthentication | NSFaceIDUsageDescription |
| Motion | CoreMotion | NSMotionUsageDescription |
| Speech | Speech framework | NSSpeechRecognitionUsageDescription |
Quality bar for the strings
A usage string should:
- be specific (“We use your location to show nearby cafés”), not vague (“Need location”)
- match the actual behaviour in the app
- avoid claiming it is “required to use the app” unless it truly is (review risk)
ATS (networking)
If NSAppTransportSecurity.NSAllowsArbitraryLoads is enabled:
- prefer removing it, or
- use targeted exceptions (
NSExceptionDomains) with justification.
Broad ATS exemptions are a common review question.
React Native (bare) iOS checks
Project layout signals
package.jsoncontainsreact-nativeios/exists with<App>.xcodeprojor<App>.xcworkspace- CocoaPods:
ios/Podfile(and oftenPodfile.lock)
High-value compliance checks
ios/<App>/Info.plistexists and contains:- version/build
- required
NS…UsageDescriptionkeys inferred from dependencies - no broad ATS exemptions unless justified
- App icon:
ios/<App>/Images.xcassets/AppIcon.appiconset/Contents.jsonexists and includesios-marketing(1024×1024) - Privacy manifest:
PrivacyInfo.xcprivacyexists (app-level) and the built Xcode privacy report is reviewed - Hermes/JSC choice is deliberate and release-tested
Suggested commands (may create artefacts)
- List schemes:
xcodebuild -list -json -workspace ios/<App>.xcworkspace- Release build for simulator:
xcodebuild -workspace ios/<App>.xcworkspace -scheme <App> -configuration Release -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
MUTATING commands (only if asked)
bundle exec pod install(writes Pods/)npm/yarn/pnpm install(writes node_modules/)
App Store Readiness Report
Summary
- Verdict: PASS | WARN | FAIL
- Project flavour: Native iOS | React Native (bare) | Expo
- iOS bundle id:
<bundle id> - Version / build:
<CFBundleShortVersionString>/<CFBundleVersion> - Audit scope: static | static + build
Evidence snapshot
- Repo:
<path> - Git:
<branch>@<short sha>(dirty: yes/no) - iOS project:
<.xcodeproj/.xcworkspace or expo config>
Failures (must fix)
Include: what failed, where, why it matters, exact fix.
- [FAIL] <check id> — <title>
- Evidence:
<file/path + key/value or command output excerpt> - Fix:
<actionable steps>
Warnings (risk / likely review issues)
- [WARN] <check id> — <title>
- Evidence: …
- Mitigation: …
Info (good to know)
- [INFO] <check id> — <title>
- Evidence: …
- Notes: …
Publish checklist (tick‑off)
Code + build
- [ ] Release build succeeds (simulator + device/archive)
- [ ] Version/build bumped and matches release notes
- [ ] Crash reporting and analytics behave as intended in Release
- [ ] No debug menus, staging endpoints, or test accounts shipped
Privacy + compliance
- [ ] Privacy manifest present and Xcode privacy report reviewed
- [ ] All permission prompts have clear usage descriptions
- [ ] App Tracking Transparency handled correctly (if applicable)
- [ ] Privacy Nutrition Labels updated to match behaviour
- [ ] Export compliance (encryption) answered correctly
Store listing + operational
- [ ] App name, subtitle, description, keywords final
- [ ] Screenshots and preview videos exported for required devices
- [ ] Support URL + Privacy Policy URL live
- [ ] Age rating and content warnings correct
- [ ] App Review notes prepared (test login, special hardware, etc.)
Recommendation
- Go/No‑Go:
<one sentence> - Next actions:
<ordered list of top fixes>
#!/usr/bin/env node
/**
* App Store readiness audit (static, mostly read-only)
*
* Usage:
* node audit.mjs --repo . --format md
* node audit.mjs --repo apps/mobile --format md --json audit.json
*
* Exit codes:
* 0 = PASS (no FAIL/WARN)
* 1 = FAIL (>=1 FAIL)
* 2 = WARN (no FAIL, >=1 WARN)
* 3 = tool error
*/
import fs from "fs";
import path from "path";
import { spawnSync } from "child_process";
const NOW = new Date().toISOString();
function failAndExit(message) {
console.error(message);
process.exit(3);
}
function parseArgs(argv) {
const args = { repo: ".", format: "md", json: null };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--repo") args.repo = argv[++i] ?? args.repo;
else if (a === "--format") args.format = argv[++i] ?? args.format;
else if (a === "--json") args.json = argv[++i] ?? null;
else if (a === "-h" || a === "--help") {
console.log(`Usage:
node audit.mjs --repo <path> [--format md|json] [--json <path>]
Notes:
--format controls stdout format (default: md).
--json writes a JSON report to disk (in addition to stdout).`);
process.exit(0);
} else {
// Unknown arg; ignore to keep CLI permissive.
}
}
return args;
}
function pathExists(p) {
try {
fs.accessSync(p, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
function isFile(p) {
try {
return fs.statSync(p).isFile();
} catch {
return false;
}
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
} catch {
return false;
}
}
function readText(p) {
return fs.readFileSync(p, "utf8");
}
function readJson(p) {
return JSON.parse(readText(p));
}
function binOnPath(bin) {
const sep = process.platform === "win32" ? ";" : ":";
const exts = process.platform === "win32" ? (process.env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
const dirs = (process.env.PATH || "").split(sep).filter(Boolean);
for (const d of dirs) {
for (const ext of exts) {
const candidate = path.join(d, process.platform === "win32" ? `${bin}${ext}` : bin);
if (pathExists(candidate)) return candidate;
}
}
return null;
}
function run(cmd, args, opts = {}) {
const res = spawnSync(cmd, args, {
cwd: opts.cwd,
env: { ...process.env, ...(opts.env || {}) },
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
shell: false,
});
return res;
}
function runShell(command, opts = {}) {
// Use the platform shell for convenience; keep output bounded.
const shell = process.platform === "win32" ? "cmd.exe" : (process.env.SHELL || "/bin/bash");
const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
return run(shell, args, opts);
}
function git(repo, args) {
const res = run("git", ["-C", repo, ...args]);
if (res.error) return { ok: false, code: 127, stdout: "", stderr: String(res.error) };
return { ok: res.status === 0, code: res.status ?? 1, stdout: res.stdout || "", stderr: res.stderr || "" };
}
function safeRel(repo, p) {
try {
return path.relative(repo, p) || ".";
} catch {
return p;
}
}
function normaliseSlashes(p) {
return p.replaceAll("\\", "/");
}
function chooseBestPath(paths, { preferContains = [], avoidContains = [] } = {}) {
if (!paths.length) return null;
const scored = paths.map((p) => {
const norm = normaliseSlashes(p);
let score = 0;
for (const s of preferContains) if (norm.includes(s)) score += 10;
for (const s of avoidContains) if (norm.includes(s)) score -= 20;
// Prefer shallower paths (less depth).
score -= norm.split("/").length;
return { p, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0].p;
}
function extractPlistString(xml, key) {
const re = new RegExp(`<key>\\s*${escapeRegExp(key)}\\s*</key>\\s*<string>([^<]*)</string>`, "i");
const m = xml.match(re);
if (!m) return null;
return decodeXml(m[1].trim());
}
function extractPlistBool(xml, key) {
const reTrue = new RegExp(`<key>\\s*${escapeRegExp(key)}\\s*</key>\\s*<true\\s*/>`, "i");
const reFalse = new RegExp(`<key>\\s*${escapeRegExp(key)}\\s*</key>\\s*<false\\s*/>`, "i");
if (reTrue.test(xml)) return true;
if (reFalse.test(xml)) return false;
return null;
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function decodeXml(s) {
return s
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll(""", "\"")
.replaceAll("'", "'");
}
function parsePlistBestEffort(filePath) {
// Strategy 1: plutil -> json
const plutil = binOnPath("plutil");
if (plutil) {
const res = run(plutil, ["-convert", "json", "-o", "-", filePath]);
if (res.status === 0 && res.stdout) {
try {
return { ok: true, data: JSON.parse(res.stdout), parser: "plutil" };
} catch {
// fallthrough
}
}
}
// Strategy 2: python3 plistlib -> json
const py = binOnPath("python3") || binOnPath("python");
if (py) {
const code = [
"import json, plistlib, sys",
"with open(sys.argv[1], 'rb') as f:",
" obj = plistlib.load(f)",
"print(json.dumps(obj))",
].join("\n");
const res = run(py, ["-c", code, filePath]);
if (res.status === 0 && res.stdout) {
try {
return { ok: true, data: JSON.parse(res.stdout), parser: "python-plistlib" };
} catch {
// fallthrough
}
}
}
// Strategy 3: regex for common keys (XML plists only)
try {
const xml = readText(filePath);
const data = {};
const keys = [
"CFBundleIdentifier",
"CFBundleShortVersionString",
"CFBundleVersion",
"CFBundleDisplayName",
"CFBundleName",
"UILaunchStoryboardName",
"NSUserTrackingUsageDescription",
"NSCameraUsageDescription",
"NSMicrophoneUsageDescription",
"NSPhotoLibraryUsageDescription",
"NSPhotoLibraryAddUsageDescription",
"NSLocationWhenInUseUsageDescription",
"NSLocationAlwaysAndWhenInUseUsageDescription",
"NSContactsUsageDescription",
"NSBluetoothAlwaysUsageDescription",
"NSFaceIDUsageDescription",
"NSSpeechRecognitionUsageDescription",
"NSMotionUsageDescription",
"NSCalendarsUsageDescription",
"NSRemindersUsageDescription",
];
for (const k of keys) {
const v = extractPlistString(xml, k);
if (v != null) data[k] = v;
}
// ATS broad exemption: NSAllowsArbitraryLoads inside NSAppTransportSecurity dict.
data.__NSAllowsArbitraryLoads = /<key>\s*NSAppTransportSecurity\s*<\/key>[\s\S]*?<key>\s*NSAllowsArbitraryLoads\s*<\/key>\s*<true\s*\/>/.test(xml);
return { ok: true, data, parser: "regex-xml" };
} catch {
return { ok: false, data: null, parser: "none" };
}
}
function readPackageJson(repo) {
const p = path.join(repo, "package.json");
if (!isFile(p)) return null;
try {
return { path: p, data: readJson(p) };
} catch {
return { path: p, data: null };
}
}
function readAppJson(repo) {
const p = path.join(repo, "app.json");
if (!isFile(p)) return null;
try {
return { path: p, data: readJson(p) };
} catch {
return { path: p, data: null };
}
}
function listFiles(repo, gitOk) {
if (gitOk) {
const res = git(repo, ["ls-files"]);
if (res.ok) {
return res.stdout.split(/\r?\n/).filter(Boolean).map((rel) => path.join(repo, rel));
}
}
// Fallback: shallow walk (avoid node_modules/Pods)
const out = [];
const IGNORE_DIRS = new Set([".git", "node_modules", "Pods", "build", "DerivedData"]);
function walk(dir, depth) {
if (depth > 6) return;
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (IGNORE_DIRS.has(e.name)) continue;
walk(p, depth + 1);
} else if (e.isFile()) {
out.push(p);
}
}
}
walk(repo, 0);
return out;
}
function detectProject(repo, pkg, appJson, files) {
const hasIosDir = isDir(path.join(repo, "ios"));
const xcodeproj = files.filter((f) => f.endsWith(".xcodeproj")).map((f) => normaliseSlashes(safeRel(repo, f)));
const xcworkspace = files.filter((f) => f.endsWith(".xcworkspace")).map((f) => normaliseSlashes(safeRel(repo, f)));
const pkgStr = pkg?.data ? JSON.stringify(pkg.data) : "";
const hasExpo = /"expo"\s*:/.test(pkgStr) || /expo/.test(pkgStr) || (appJson?.data && Object.prototype.hasOwnProperty.call(appJson.data, "expo"));
const hasReactNative = /"react-native"\s*:/.test(pkgStr) || /react-native/.test(pkgStr);
let flavour = "unknown";
if (hasExpo) flavour = "expo";
else if (hasReactNative && hasIosDir) flavour = "react-native";
else if (xcodeproj.length || xcworkspace.length) flavour = "native-ios";
// Choose iOS container (xcode project/workspace)
const projectPaths = [...xcworkspace, ...xcodeproj];
const bestProject = chooseBestPath(projectPaths, { preferContains: ["/ios/"], avoidContains: ["/Pods/"] });
return {
flavour,
hasIosDir,
xcodeproj,
xcworkspace,
bestProject,
};
}
function detectInfoPlist(repo, files) {
const candidates = files
.filter((f) => f.endsWith("Info.plist"))
.map((f) => normaliseSlashes(safeRel(repo, f)))
.filter((rel) => !rel.includes("/Pods/") && !rel.includes("/node_modules/") && !rel.includes("/build/") && !rel.includes("/DerivedData/"));
const best = chooseBestPath(candidates, {
preferContains: ["/ios/"],
avoidContains: ["/Tests/", "/UITests/", "/Example/", "/Examples/"],
});
return { candidates, best };
}
function detectEntitlements(repo, files) {
const candidates = files
.filter((f) => f.endsWith(".entitlements"))
.map((f) => normaliseSlashes(safeRel(repo, f)))
.filter((rel) => !rel.includes("/Pods/") && !rel.includes("/node_modules/"));
const best = chooseBestPath(candidates, { preferContains: ["/ios/"], avoidContains: ["/Pods/"] });
return { candidates, best };
}
function detectPrivacyManifest(repo, files) {
const candidates = files
.filter((f) => f.endsWith("PrivacyInfo.xcprivacy"))
.map((f) => normaliseSlashes(safeRel(repo, f)))
.filter((rel) => !rel.includes("/Pods/") && !rel.includes("/node_modules/") && !rel.includes("/build/") && !rel.includes("/DerivedData/"));
const best = chooseBestPath(candidates, { preferContains: ["/ios/"], avoidContains: [] });
return { candidates, best };
}
function detectAppIcon(repo, files) {
const candidates = files
.filter((f) => f.endsWith("AppIcon.appiconset/Contents.json"))
.map((f) => normaliseSlashes(safeRel(repo, f)))
.filter((rel) => !rel.includes("/Pods/") && !rel.includes("/node_modules/"));
const best = chooseBestPath(candidates, { preferContains: ["/ios/"], avoidContains: [] });
return { candidates, best };
}
function detectLaunchScreen(repo, files) {
const candidates = files
.filter((f) => /LaunchScreen\.(storyboard|xib)$/.test(f))
.map((f) => normaliseSlashes(safeRel(repo, f)))
.filter((rel) => !rel.includes("/Pods/") && !rel.includes("/node_modules/"));
const best = chooseBestPath(candidates, { preferContains: ["/ios/"], avoidContains: [] });
return { candidates, best };
}
function detectPrivacyPolicyArtifact(repo, files) {
const patterns = [
/^PRIVACY\.md$/i,
/^PRIVACY_POLICY\.md$/i,
/privacy.*policy/i,
];
const rels = files.map((f) => normaliseSlashes(safeRel(repo, f)));
for (const re of patterns) {
const hit = rels.find((r) => re.test(path.basename(r)) || re.test(r));
if (hit) return hit;
}
// Fall back: README contains "privacy policy"
const readmes = rels.filter((r) => /^README(\..*)?$/i.test(path.basename(r)));
for (const r of readmes) {
try {
const t = readText(path.join(repo, r));
if (/privacy policy/i.test(t)) return r;
} catch { /* ignore */ }
}
return null;
}
function detectSecrets(repo, files, gitOk) {
const rels = files.map((f) => normaliseSlashes(safeRel(repo, f)));
const badExt = [
".p12",
".p8",
".mobileprovision",
".keystore",
".jks",
".pem",
".cer",
".der",
];
const bad = rels.filter((r) => badExt.some((ext) => r.toLowerCase().endsWith(ext)));
const envFiles = rels.filter((r) => /(^|\/)\.env(\.|$)/i.test(r));
const sshKeys = rels.filter((r) => /id_rsa|id_ed25519/i.test(path.basename(r)));
// Also check git-ignored secrets that are still tracked (common pitfall).
let gitIgnoredTracked = [];
if (gitOk) {
const res = git(repo, ["check-ignore", "-v", ...bad]);
if (res.ok && res.stdout) {
gitIgnoredTracked = res.stdout.split(/\r?\n/).filter(Boolean).map((l) => l.trim());
}
}
return { bad, envFiles, sshKeys, gitIgnoredTracked };
}
function inferRequiredUsageStrings({ flavour, pkg, appJson, files }) {
const required = new Map(); // key -> reasons
const add = (key, reason) => {
const arr = required.get(key) || [];
arr.push(reason);
required.set(key, arr);
};
const depBlob = pkg?.data ? JSON.stringify({ dependencies: pkg.data.dependencies, devDependencies: pkg.data.devDependencies }) : "";
const fileBlob = files.length ? files.map((f) => normaliseSlashes(f)).join("\n") : "";
const match = (re) => re.test(depBlob);
// Expo/RN dependency signals
if (match(/expo-camera|react-native-camera|vision-camera/i)) add("NSCameraUsageDescription", "camera dependency present");
if (match(/expo-av|expo-audio|react-native-audio|react-native-voice|react-native-sound/i)) add("NSMicrophoneUsageDescription", "audio/mic dependency present");
if (match(/expo-media-library|react-native-image-picker|react-native-photo/i)) {
add("NSPhotoLibraryUsageDescription", "photo/media dependency present");
add("NSPhotoLibraryAddUsageDescription", "photo/media dependency present");
}
if (match(/expo-location|@react-native-community\/geolocation|react-native-geolocation-service/i)) add("NSLocationWhenInUseUsageDescription", "location dependency present");
if (match(/expo-contacts|react-native-contacts/i)) add("NSContactsUsageDescription", "contacts dependency present");
if (match(/expo-tracking-transparency|react-native-tracking-transparency/i)) add("NSUserTrackingUsageDescription", "tracking transparency dependency present");
if (match(/firebase|fb(ad)?sdk|appsflyer|adjust|branch/i)) add("NSUserTrackingUsageDescription", "attribution/analytics SDK present (verify tracking)");
// Native code signals (best-effort grep via filenames only; avoids reading all code).
// If the repo is native iOS and has these framework references, usage strings are likely required.
if (flavour === "native-ios") {
if (/CLLocationManager/.test(fileBlob)) add("NSLocationWhenInUseUsageDescription", "CLLocationManager symbol appears in repo paths");
if (/AVCapture/.test(fileBlob)) add("NSCameraUsageDescription", "AVCapture symbol appears in repo paths");
}
// Expo config can also encode permissions explicitly; surface as info only.
if (flavour === "expo" && appJson?.data) {
const infoPlist = appJson.data?.expo?.ios?.infoPlist;
if (infoPlist && typeof infoPlist === "object") {
for (const k of Object.keys(infoPlist)) {
if (/^NS[A-Z].*UsageDescription$/.test(k)) add(k, "present in expo.ios.infoPlist (config)");
}
}
}
return required;
}
function checkNonEmptyString(val) {
if (typeof val !== "string") return false;
const t = val.trim();
if (!t) return false;
if (/^todo\b|^tbd\b|^fixme\b/i.test(t)) return false;
return t.length >= 6; // heuristic
}
function runAudit(repo) {
const checks = [];
const gitInside = git(repo, ["rev-parse", "--is-inside-work-tree"]);
const gitOk = gitInside.ok && gitInside.stdout.trim() === "true";
const files = listFiles(repo, gitOk);
const pkg = readPackageJson(repo);
const appJson = readAppJson(repo);
const project = detectProject(repo, pkg, appJson, files);
// Repo hygiene
if (gitOk) {
const st = git(repo, ["status", "--porcelain"]);
const dirty = st.ok && st.stdout.trim().length > 0;
checks.push({
id: "git-clean",
status: dirty ? "WARN" : "PASS",
title: "Git working tree is clean",
evidence: dirty ? st.stdout.split(/\r?\n/).slice(0, 20).join("\n") : "clean",
remediation: dirty ? "Commit/stash changes before cutting a release build." : null,
});
const sha = git(repo, ["rev-parse", "--short", "HEAD"]);
const branch = git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
const tag = git(repo, ["describe", "--tags", "--abbrev=0"]);
checks.push({
id: "git-snapshot",
status: "INFO",
title: "Git snapshot",
evidence: `branch=${branch.ok ? branch.stdout.trim() : "?"} sha=${sha.ok ? sha.stdout.trim() : "?"} latestTag=${tag.ok ? tag.stdout.trim() : "none"}`,
remediation: null,
});
} else {
checks.push({
id: "git-repo",
status: "WARN",
title: "Repository is not a git worktree (or git unavailable)",
evidence: gitInside.stderr.trim() || "not a git repo",
remediation: "Use git tags/branches to track the exact build submitted to App Store Connect.",
});
}
// Secrets scan
const secrets = detectSecrets(repo, files, gitOk);
if (secrets.bad.length || secrets.sshKeys.length) {
checks.push({
id: "secrets-artifacts",
status: "FAIL",
title: "Potential secret/signing artifacts tracked in repo",
evidence: [...secrets.bad, ...secrets.sshKeys].slice(0, 50).join("\n"),
remediation: "Remove these files from the repo history and rotate any compromised credentials. Use keychain/CI secrets instead.",
});
} else if (secrets.envFiles.length) {
checks.push({
id: "secrets-env",
status: "WARN",
title: "Possible .env files tracked in repo",
evidence: secrets.envFiles.slice(0, 50).join("\n"),
remediation: "Ensure .env files contain no secrets, or remove from git and document a template (.env.example).",
});
} else {
checks.push({
id: "secrets-scan",
status: "PASS",
title: "No obvious secret/signing files detected in tracked files",
evidence: "checked common secret extensions (.p12/.p8/.mobileprovision/.pem/.keystore) and .env patterns",
remediation: null,
});
}
// Detect Info.plist
const info = detectInfoPlist(repo, files);
let infoPlist = null;
if (info.best) {
const abs = path.join(repo, info.best);
const parsed = parsePlistBestEffort(abs);
if (parsed.ok) infoPlist = { path: info.best, parsed };
}
if (!infoPlist) {
checks.push({
id: "info-plist",
status: project.flavour === "expo" ? "WARN" : "FAIL",
title: "Info.plist found and parseable",
evidence: info.candidates.slice(0, 20).join("\n") || "no Info.plist candidates found",
remediation: project.flavour === "expo"
? "Expo managed projects may not have ios/ committed. Ensure iOS config (bundle id, version/build, permissions) is defined in app.json/app.config and validated via EAS/Xcode."
: "Ensure the iOS target has an Info.plist in the repo, and that the audit tool can access it.",
});
} else {
const d = infoPlist.parsed.data || {};
const bundleId = d.CFBundleIdentifier || null;
const version = d.CFBundleShortVersionString || null;
const build = d.CFBundleVersion || null;
checks.push({
id: "bundle-id",
status: bundleId ? "PASS" : "FAIL",
title: "Bundle identifier is present",
evidence: bundleId ? `${infoPlist.path}: CFBundleIdentifier=${bundleId}` : `${infoPlist.path}: CFBundleIdentifier missing`,
remediation: bundleId ? null : "Set PRODUCT_BUNDLE_IDENTIFIER in Xcode build settings (and/or CFBundleIdentifier in Info.plist).",
});
checks.push({
id: "version-build",
status: version && build ? "PASS" : "FAIL",
title: "Version and build number are present",
evidence: `${infoPlist.path}: CFBundleShortVersionString=${version ?? "missing"}; CFBundleVersion=${build ?? "missing"}`,
remediation: version && build ? null : "Set CFBundleShortVersionString (marketing version) and CFBundleVersion (build number).",
});
const ats = d.__NSAllowsArbitraryLoads === true;
checks.push({
id: "ats",
status: ats ? "WARN" : "PASS",
title: "No broad App Transport Security exemption",
evidence: ats ? `${infoPlist.path}: NSAllowsArbitraryLoads=true` : "NSAllowsArbitraryLoads not detected (best-effort)",
remediation: ats ? "Remove NSAllowsArbitraryLoads or scope to NSExceptionDomains with justification." : null,
});
const launch = d.UILaunchStoryboardName || null;
if (launch) {
checks.push({
id: "launch-storyboard-key",
status: "INFO",
title: "Launch screen storyboard configured",
evidence: `${infoPlist.path}: UILaunchStoryboardName=${launch}`,
remediation: null,
});
}
}
// App icon
const icon = detectAppIcon(repo, files);
if (!icon.best) {
// Expo may use app.json icon
const expoIconPath = project.flavour === "expo" ? (appJson?.data?.expo?.ios?.icon || appJson?.data?.expo?.icon || null) : null;
if (expoIconPath) {
const abs = path.join(repo, expoIconPath);
checks.push({
id: "app-icon",
status: isFile(abs) ? "PASS" : "FAIL",
title: "App icon configured",
evidence: `expo icon=${expoIconPath} (${isFile(abs) ? "exists" : "missing file"})`,
remediation: isFile(abs) ? null : "Fix expo icon path or add the referenced icon file.",
});
} else {
checks.push({
id: "app-icon",
status: "FAIL",
title: "App icon present (AppIcon.appiconset or Expo icon)",
evidence: icon.candidates.slice(0, 10).join("\n") || "no AppIcon.appiconset/Contents.json found",
remediation: "Ensure the iOS project includes an AppIcon asset with an iOS-marketing 1024×1024 icon, or configure expo.icon/expo.ios.icon.",
});
}
} else {
const abs = path.join(repo, icon.best);
let ok = false;
let detail = "";
try {
const j = readJson(abs);
const images = Array.isArray(j.images) ? j.images : [];
ok = images.some((im) => im.idiom === "ios-marketing" && im.size === "1024x1024");
detail = ok ? "found ios-marketing 1024×1024" : "missing ios-marketing 1024×1024 entry";
} catch (e) {
detail = `failed to parse JSON: ${String(e)}`;
}
checks.push({
id: "app-icon",
status: ok ? "PASS" : "FAIL",
title: "App Store icon (ios-marketing 1024×1024) present",
evidence: `${icon.best}: ${detail}`,
remediation: ok ? null : "Update AppIcon.appiconset/Contents.json to include an ios-marketing 1024×1024 icon.",
});
}
// Launch screen
const launchScreen = detectLaunchScreen(repo, files);
if (launchScreen.best) {
checks.push({
id: "launch-screen",
status: "PASS",
title: "Launch screen storyboard/xib present",
evidence: launchScreen.best,
remediation: null,
});
} else {
// If Info.plist had UILaunchStoryboardName we already emitted INFO; still warn if file missing.
checks.push({
id: "launch-screen",
status: "WARN",
title: "Launch screen storyboard/xib present",
evidence: "No LaunchScreen.storyboard or LaunchScreen.xib found in tracked files",
remediation: "Ensure a launch screen exists and is referenced (UILaunchStoryboardName).",
});
}
// Privacy manifest
const privacy = detectPrivacyManifest(repo, files);
if (privacy.best) {
checks.push({
id: "privacy-manifest",
status: "PASS",
title: "Privacy manifest (PrivacyInfo.xcprivacy) present",
evidence: privacy.best,
remediation: null,
});
} else {
checks.push({
id: "privacy-manifest",
status: "WARN",
title: "Privacy manifest (PrivacyInfo.xcprivacy) present",
evidence: "No PrivacyInfo.xcprivacy found in tracked files",
remediation: "Add an app-level PrivacyInfo.xcprivacy if required (and review Xcode’s Privacy Report for third-party SDK manifests and required reason APIs).",
});
}
// Permission usage strings (best-effort inference)
const requiredStrings = inferRequiredUsageStrings({ flavour: project.flavour, pkg, appJson, files: files.map((f) => normaliseSlashes(safeRel(repo, f))) });
const missing = [];
const present = [];
if (requiredStrings.size) {
if (infoPlist?.parsed?.data) {
const d = infoPlist.parsed.data;
for (const [key, reasons] of requiredStrings.entries()) {
const val = d[key];
if (checkNonEmptyString(val)) present.push({ key, reasons, val });
else missing.push({ key, reasons, val });
}
} else if (project.flavour === "expo" && appJson?.data?.expo?.ios?.infoPlist) {
const d = appJson.data.expo.ios.infoPlist;
for (const [key, reasons] of requiredStrings.entries()) {
const val = d[key];
if (checkNonEmptyString(val)) present.push({ key, reasons, val });
else missing.push({ key, reasons, val });
}
} else {
// Can't evaluate; emit warning.
missing.push(...Array.from(requiredStrings.keys()).map((k) => ({ key: k, reasons: requiredStrings.get(k) || [], val: null })));
}
}
if (requiredStrings.size === 0) {
checks.push({
id: "permission-strings",
status: "INFO",
title: "Permission usage strings (inferred) — none detected",
evidence: "No strong signals for camera/location/tracking/etc in dependencies (best-effort).",
remediation: null,
});
} else if (missing.length) {
checks.push({
id: "permission-strings",
status: infoPlist ? "FAIL" : "WARN",
title: "Permission usage strings present for inferred capabilities",
evidence: missing.slice(0, 20).map((m) => `${m.key} (because: ${m.reasons.join("; ")})`).join("\n"),
remediation: "Add the missing NS…UsageDescription keys with human-readable explanations (see references/permissions-map.md).",
});
} else {
checks.push({
id: "permission-strings",
status: "PASS",
title: "Permission usage strings present for inferred capabilities",
evidence: present.slice(0, 20).map((m) => `${m.key}="${String(m.val).trim()}"`).join("\n"),
remediation: null,
});
}
// Expo config basics
if (project.flavour === "expo") {
const expo = appJson?.data?.expo;
if (!expo) {
checks.push({
id: "expo-config",
status: "WARN",
title: "Expo config (app.json) parseable",
evidence: appJson?.path ? `${appJson.path} failed to parse or missing expo key` : "app.json missing; config may be app.config.js/ts",
remediation: "Ensure the resolved Expo config contains ios.bundleIdentifier, version, and buildNumber. Consider running `npx expo config --type public`.",
});
} else {
const bundle = expo.ios?.bundleIdentifier;
const version = expo.version;
const build = expo.ios?.buildNumber;
const problems = [];
if (!bundle) problems.push("expo.ios.bundleIdentifier missing");
if (!version) problems.push("expo.version missing");
if (!build) problems.push("expo.ios.buildNumber missing");
checks.push({
id: "expo-config",
status: problems.length ? "FAIL" : "PASS",
title: "Expo iOS publishing config present",
evidence: problems.length ? problems.join("; ") : `bundleIdentifier=${bundle}; version=${version}; buildNumber=${build}`,
remediation: problems.length ? "Fill in the missing Expo config fields required for iOS publishing." : null,
});
}
}
// Store listing: privacy policy artefact
const pp = detectPrivacyPolicyArtifact(repo, files);
checks.push({
id: "privacy-policy",
status: pp ? "PASS" : "WARN",
title: "Privacy policy URL or document present in repo",
evidence: pp ? `Found: ${pp}` : "No obvious PRIVACY.md or 'privacy policy' reference found",
remediation: pp ? null : "Ensure you have a public Privacy Policy URL for App Store Connect, and that it matches actual data practices.",
});
// Entitlements presence (informational)
const ent = detectEntitlements(repo, files);
if (ent.best) {
checks.push({
id: "entitlements",
status: "INFO",
title: "Entitlements file detected",
evidence: ent.best,
remediation: "Verify enabled capabilities match entitlements and App Store Connect settings (Push/iCloud/Associated Domains/etc.).",
});
} else {
checks.push({
id: "entitlements",
status: "INFO",
title: "No entitlements file detected",
evidence: "Not necessarily a problem (some apps have no special entitlements).",
remediation: null,
});
}
// Summarise project type
checks.push({
id: "project-detect",
status: "INFO",
title: "Detected project flavour",
evidence: `flavour=${project.flavour}; iosDir=${project.hasIosDir}; project=${project.bestProject ?? "n/a"}`,
remediation: null,
});
// Compute verdict
const hasFail = checks.some((c) => c.status === "FAIL");
const hasWarn = checks.some((c) => c.status === "WARN");
const verdict = hasFail ? "FAIL" : hasWarn ? "WARN" : "PASS";
// Key identifiers (best-effort)
const bundleId = infoPlist?.parsed?.data?.CFBundleIdentifier || (appJson?.data?.expo?.ios?.bundleIdentifier ?? null);
const version = infoPlist?.parsed?.data?.CFBundleShortVersionString || (appJson?.data?.expo?.version ?? null);
const build = infoPlist?.parsed?.data?.CFBundleVersion || (appJson?.data?.expo?.ios?.buildNumber ?? null);
const meta = {
timestamp: NOW,
repo: path.resolve(repo),
platform: process.platform,
flavour: project.flavour,
bundleId,
version,
build,
infoPlist: infoPlist?.path ?? null,
privacyManifest: privacy.best ?? null,
appIcon: icon.best ?? null,
launchScreen: launchScreen.best ?? null,
};
return { verdict, meta, checks };
}
function formatMarkdown(report) {
const { verdict, meta, checks } = report;
const byStatus = (s) => checks.filter((c) => c.status === s);
const fails = byStatus("FAIL");
const warns = byStatus("WARN");
const infos = byStatus("INFO");
const passes = byStatus("PASS");
const lines = [];
lines.push(`# App Store Readiness Audit`);
lines.push(``);
lines.push(`- **Verdict:** ${verdict}`);
lines.push(`- **Flavour:** ${meta.flavour}`);
lines.push(`- **Repo:** ${meta.repo}`);
if (meta.bundleId) lines.push(`- **Bundle id:** ${meta.bundleId}`);
if (meta.version || meta.build) lines.push(`- **Version/build:** ${meta.version ?? "?"} / ${meta.build ?? "?"}`);
lines.push(`- **Timestamp:** ${meta.timestamp}`);
lines.push(``);
lines.push(`## Summary`);
lines.push(`- FAIL: ${fails.length}`);
lines.push(`- WARN: ${warns.length}`);
lines.push(`- PASS: ${passes.length}`);
lines.push(`- INFO: ${infos.length}`);
lines.push(``);
function renderList(items, heading) {
if (!items.length) return;
lines.push(`## ${heading}`);
for (const c of items) {
lines.push(`### [${c.status}] ${c.id} — ${c.title}`);
if (c.evidence) {
lines.push(`**Evidence:**`);
lines.push("```");
lines.push(String(c.evidence).trimEnd());
lines.push("```");
}
if (c.remediation) {
lines.push(`**Remediation:** ${c.remediation}`);
}
lines.push("");
}
}
renderList(fails, "Failures (must fix before submitting)");
renderList(warns, "Warnings (risk areas / likely review questions)");
// Keep PASS/INFO concise to avoid noisy output.
if (passes.length) {
lines.push(`## Passes (selected)`);
for (const c of passes.slice(0, 8)) lines.push(`- [PASS] ${c.id} — ${c.title}`);
if (passes.length > 8) lines.push(`- …and ${passes.length - 8} more passes`);
lines.push("");
}
if (infos.length) {
lines.push(`## Info`);
for (const c of infos) lines.push(`- [INFO] ${c.id}: ${c.evidence}`);
lines.push("");
}
lines.push(`## Publish checklist (manual + build)`);
lines.push(`- [ ] Release build succeeds (simulator + device/archive)`);
lines.push(`- [ ] Version/build bumped and matches release notes`);
lines.push(`- [ ] Privacy manifest present and Xcode privacy report reviewed`);
lines.push(`- [ ] Permission prompts have clear usage descriptions`);
lines.push(`- [ ] Privacy Policy URL live and matches behaviour`);
lines.push(`- [ ] App Store Connect screenshots/metadata ready`);
lines.push(`- [ ] Export compliance (encryption) answers verified`);
lines.push(``);
return lines.join("\n");
}
function main() {
const args = parseArgs(process.argv);
const repo = path.resolve(args.repo);
if (!isDir(repo)) failAndExit(`Repo path does not exist or is not a directory: ${repo}`);
let report;
try {
report = runAudit(repo);
} catch (e) {
failAndExit(`Audit failed: ${String(e)}`);
}
const out = args.format === "json" ? JSON.stringify(report, null, 2) : formatMarkdown(report);
process.stdout.write(out + (out.endsWith("\n") ? "" : "\n"));
if (args.json) {
try {
fs.writeFileSync(path.resolve(repo, args.json), JSON.stringify(report, null, 2), "utf8");
} catch (e) {
console.error(`Failed to write JSON report to ${args.json}: ${String(e)}`);
}
}
const hasFail = report.checks.some((c) => c.status === "FAIL");
const hasWarn = report.checks.some((c) => c.status === "WARN");
process.exit(hasFail ? 1 : hasWarn ? 2 : 0);
}
main();