← Files OneSignalARCHIVED FILE
skills/setup/scripts/detect_platform.py
11.3 KB · Sep 30, 2026 · 22:50 UTC
#!/usr/bin/env python3
"""Deterministic platform / framework / package-manager detection for setup.
Read-only. Never executes repo code (safety contract §12: repo text is
untrusted data). Detects per-package so monorepos don't collapse to one
platform, reports the package manager and language, and flags any prior
OneSignal install so setup can choose update/repair over a duplicate.
Usage:
detect_platform.py [root] # defaults to CWD
Output: JSON on stdout:
{
"root": "...",
"packages": [
{"dir": ".", "platform": "android", "language": "kotlin",
"package_manager": "gradle", "signals": [...]}
],
"prior_onesignal": {"found": bool, "evidence": [{"file","line","match"}]},
"ambiguous": bool
}
`ambiguous` is true when a package matched more than one platform signal or
nothing matched — setup must ASK, not guess.
"""
import json
import os
import re
import sys
EXCLUDE_DIRS = {
"node_modules", "dist", "build", ".next", ".nuxt", "vendor", "Pods",
".git", ".gradle", "DerivedData", "Carthage", ".dart_tool", "out",
"coverage", "__pycache__", ".venv", "venv",
}
# Ordered strongest-signal-first. Each rule: (platform, predicate(pkgdir)->signal|None)
def _has(pkgdir, *names):
for n in names:
if os.path.exists(os.path.join(pkgdir, n)):
return n
return None
def _pkg_json(pkgdir):
p = os.path.join(pkgdir, "package.json")
if not os.path.isfile(p):
return None
try:
with open(p, encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def _deps(pj):
d = {}
for k in ("dependencies", "devDependencies", "peerDependencies"):
d.update(pj.get(k) or {})
return d
def detect_platform(pkgdir, root=None):
signals = []
pj = _pkg_json(pkgdir)
deps = _deps(pj) if pj else {}
candidates = []
# Expo: strongest mobile-JS signal
app_cfg = _has(pkgdir, "app.json", "app.config.js", "app.config.ts")
if (pj and "expo" in deps) or (app_cfg and _app_has_expo(pkgdir, app_cfg)):
candidates.append(("expo", f"expo dep / {app_cfg or 'expo dependency'}"))
if pj and "react-native" in deps and "expo" not in deps:
candidates.append(("react-native", "react-native dep (no expo)"))
if _has(pkgdir, "pubspec.yaml"):
candidates.append(("flutter", "pubspec.yaml"))
if _has(pkgdir, "capacitor.config.ts", "capacitor.config.js", "capacitor.config.json") or "@capacitor/core" in deps:
candidates.append(("capacitor", "capacitor config / @capacitor/core"))
if _has(pkgdir, "config.xml") and pj and "cordova" in deps:
candidates.append(("cordova", "config.xml + cordova dep"))
if _glob_any(pkgdir, r"\.csproj$") and (_has(pkgdir, "ProjectSettings") or _has(pkgdir, "Assets")):
candidates.append(("unity", "csproj + ProjectSettings/Assets"))
# A cross-platform framework owns the native ios/ AND android/ folders
# (Flutter's Runner, RN/Expo/Capacitor/Cordova) — the package to integrate is
# the JS/Dart one, not "ios"/"android". Both native rules share the same guard,
# or such a project reports a spurious native co-candidate (ambiguous → the
# skill needlessly asks which package to integrate).
_hybrid_js = pj and any(d in deps for d in ("react-native", "expo", "@capacitor/core", "cordova"))
_has_capacitor = _has(pkgdir, "capacitor.config.ts", "capacitor.config.js", "capacitor.config.json")
_cross_platform = _has(pkgdir, "pubspec.yaml") or _hybrid_js or _has_capacitor
if ((_has(pkgdir, "Podfile") or _glob_any(pkgdir, r"\.xcodeproj$", r"\.xcworkspace$") or _appdelegate(pkgdir))
and not _cross_platform):
candidates.append(("ios", "Podfile / xcodeproj / AppDelegate"))
gradle_file = (_has(pkgdir, "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts")
or _find_file(pkgdir, "build.gradle") or _find_file(pkgdir, "build.gradle.kts"))
if (gradle_file
and _find_file(pkgdir, "AndroidManifest.xml")
and not _cross_platform):
candidates.append(("android", "build.gradle + AndroidManifest.xml"))
if pj and any(w in deps for w in ("next", "react-dom", "vue", "@angular/core", "svelte", "vite")):
candidates.append(("web", "web framework dep"))
elif _has(pkgdir, "index.html") and not candidates:
candidates.append(("web", "index.html, no native project"))
platform = candidates[0][0] if candidates else None
signals = [c[1] for c in candidates]
ambiguous = len(candidates) != 1
rel = os.path.relpath(pkgdir, root) if root else os.path.relpath(pkgdir)
return {
"dir": rel or ".",
"platform": platform,
"candidates": [c[0] for c in candidates],
"language": _language(pkgdir, platform),
"package_manager": _pkg_manager(pkgdir, platform),
"signals": signals,
}, ambiguous
def _app_has_expo(pkgdir, app_cfg):
try:
with open(os.path.join(pkgdir, app_cfg), encoding="utf-8") as f:
return "expo" in f.read()
except Exception:
return False
def _appdelegate(pkgdir):
return bool(_find_file(pkgdir, "AppDelegate.swift") or _find_file(pkgdir, "AppDelegate.m"))
def _find_swift(root, max_depth=4):
root = os.path.abspath(root)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
if dirpath[len(root):].count(os.sep) > max_depth:
dirnames[:] = []
continue
if any(f.endswith(".swift") for f in filenames):
return True
return False
def _glob_any(pkgdir, *patterns):
try:
for name in os.listdir(pkgdir):
for p in patterns:
if re.search(p, name):
return name
except Exception:
pass
return None
def _find_file(root, target, max_depth=4):
root = os.path.abspath(root)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
depth = dirpath[len(root):].count(os.sep)
if depth > max_depth:
dirnames[:] = []
continue
if target in filenames:
return os.path.join(dirpath, target)
return None
def _language(pkgdir, platform):
if platform in ("web", "react-native", "expo", "capacitor", "cordova"):
pj = _pkg_json(pkgdir)
if pj and ("typescript" in _deps(pj) or _has(pkgdir, "tsconfig.json")):
return "typescript"
return "javascript"
if platform == "android":
return "kotlin" if _find_file(pkgdir, "MainActivity.kt") or _glob_any(pkgdir, r"\.kts$") else "java"
if platform == "ios":
if _find_file(pkgdir, "AppDelegate.swift") or _glob_any(pkgdir, r"\.swift$") or _find_swift(pkgdir):
return "swift"
return "objc"
if platform == "flutter":
return "dart"
return None
def _pkg_manager(pkgdir, platform):
lock = _has(pkgdir, "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock",
"Podfile.lock", "Package.resolved", "pubspec.lock")
mapping = {
"package-lock.json": "npm", "yarn.lock": "yarn", "pnpm-lock.yaml": "pnpm",
"bun.lock": "bun", "Podfile.lock": "cocoapods", "Package.resolved": "spm",
"pubspec.lock": "pub",
}
if platform == "android":
return "gradle"
return mapping.get(lock)
def find_packages(root):
"""Return package dirs. Monorepo-aware via workspaces / marker files."""
root = os.path.abspath(root)
pkgs = set()
pj = _pkg_json(root)
monorepo = False
if pj and pj.get("workspaces"):
monorepo = True
for marker in ("pnpm-workspace.yaml", "lerna.json", "nx.json", "turbo.json"):
if os.path.exists(os.path.join(root, marker)):
monorepo = True
# Always consider root itself.
pkgs.add(root)
if monorepo:
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
if dirpath == root:
continue
if "package.json" in filenames or "pubspec.yaml" in filenames or "build.gradle" in filenames:
pkgs.add(dirpath)
return sorted(pkgs), monorepo
PRIOR_PATTERNS = [
(r"com\.onesignal:OneSignal", "gradle dependency"),
(r"react-native-onesignal", "RN package"),
(r"onesignal_flutter", "flutter package"),
(r"onesignal-cordova-plugin", "cordova plugin"),
(r"@onesignal/capacitor-plugin", "capacitor plugin"),
(r"OneSignalXCFramework|OneSignal-iOS-SDK", "iOS SDK"),
(r"OneSignal\.(init|initialize|initWithContext)", "init call"),
(r"onesignal:managed", "managed marker"),
(r"OneSignalSDKWorker", "web service worker"),
]
SCAN_EXTS = {".json", ".gradle", ".kts", ".swift", ".m", ".kt", ".java", ".dart",
".ts", ".tsx", ".js", ".jsx", ".yaml", ".yml", ".html", ".xml", ".rb"}
def detect_prior(root):
root = os.path.abspath(root)
evidence = []
compiled = [(re.compile(p), label) for p, label in PRIOR_PATTERNS]
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for fn in filenames:
ext = os.path.splitext(fn)[1]
if ext not in SCAN_EXTS and fn != "Podfile":
continue
fp = os.path.join(dirpath, fn)
try:
with open(fp, encoding="utf-8", errors="ignore") as f:
for i, line in enumerate(f, 1):
for rx, label in compiled:
if rx.search(line):
evidence.append({
"file": os.path.relpath(fp, root),
"line": i,
"match": label,
})
break
except Exception:
continue
if len(evidence) >= 50:
break
return {"found": bool(evidence), "evidence": evidence}
def main():
root = sys.argv[1] if len(sys.argv) > 1 else "."
if not os.path.isdir(root):
sys.stderr.write(f"ERROR: not a directory: {root}\n")
sys.exit(2)
pkg_dirs, monorepo = find_packages(root)
packages = []
any_ambiguous = False
for d in pkg_dirs:
info, ambiguous = detect_platform(d, root=os.path.abspath(root))
if info["platform"] is None and d != os.path.abspath(root):
continue # skip noise sub-packages with no platform signal
if info["platform"] is None and len(pkg_dirs) > 1:
continue
packages.append(info)
any_ambiguous = any_ambiguous or ambiguous
packages = [p for p in packages if p["platform"] or len(packages) == 1]
out = {
"root": os.path.abspath(root),
"monorepo": monorepo,
"packages": packages,
"prior_onesignal": detect_prior(root),
"ambiguous": any_ambiguous or len(packages) > 1 or (packages and packages[0]["platform"] is None),
"guidance": ("Multiple packages or ambiguous signals — ASK the user which "
"package(s) to integrate; do not guess."
if (any_ambiguous or len(packages) > 1) else
"Single unambiguous platform detected."),
}
print(json.dumps(out, indent=2))
if __name__ == "__main__":
main()
SHA-256: 37fb2f02367007e1bdb9181274a5ac70dbc5a22df5c17c6301ad3a79cbb076a4