|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * Capacitor plugin wiring/name checker. |
| 4 | + * |
| 5 | + * Enforces that runtime plugin name matches across: |
| 6 | + * - JS: registerPlugin('Name') |
| 7 | + * - Android: @CapacitorPlugin(name = "Name") |
| 8 | + * - iOS: CAPBridgedPlugin jsName = "Name" |
| 9 | + * |
| 10 | + * And (when iOS is declared in package.json capacitor config): |
| 11 | + * - CocoaPods podspec s.name matches SwiftPM Package(name: ...) and .library(name: ...) |
| 12 | + * |
| 13 | + * Usage: |
| 14 | + * node tools/check-capacitor-plugin-wiring.mjs # checks current working dir |
| 15 | + * node tools/check-capacitor-plugin-wiring.mjs --dir path # checks given plugin dir |
| 16 | + */ |
| 17 | + |
| 18 | +import fs from "node:fs"; |
| 19 | +import path from "node:path"; |
| 20 | + |
| 21 | +const SKIP_DIRS = new Set([ |
| 22 | + "node_modules", |
| 23 | + "dist", |
| 24 | + "build", |
| 25 | + ".build", |
| 26 | + ".gradle", |
| 27 | + "Pods", |
| 28 | + "DerivedData", |
| 29 | + ".swiftpm", |
| 30 | + ".git", |
| 31 | +]); |
| 32 | + |
| 33 | +function readText(p) { |
| 34 | + try { |
| 35 | + return fs.readFileSync(p, "utf8"); |
| 36 | + } catch { |
| 37 | + return ""; |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function exists(p) { |
| 42 | + try { |
| 43 | + fs.accessSync(p); |
| 44 | + return true; |
| 45 | + } catch { |
| 46 | + return false; |
| 47 | + } |
| 48 | +} |
| 49 | + |
| 50 | +function walkFiles(rootDir, exts) { |
| 51 | + const out = []; |
| 52 | + const stack = [rootDir]; |
| 53 | + while (stack.length) { |
| 54 | + const dir = stack.pop(); |
| 55 | + let entries; |
| 56 | + try { |
| 57 | + entries = fs.readdirSync(dir, { withFileTypes: true }); |
| 58 | + } catch { |
| 59 | + continue; |
| 60 | + } |
| 61 | + for (const e of entries) { |
| 62 | + if (e.isDirectory()) { |
| 63 | + if (SKIP_DIRS.has(e.name)) continue; |
| 64 | + stack.push(path.join(dir, e.name)); |
| 65 | + continue; |
| 66 | + } |
| 67 | + if (!e.isFile()) continue; |
| 68 | + for (const ext of exts) { |
| 69 | + if (e.name.endsWith(ext)) { |
| 70 | + out.push(path.join(dir, e.name)); |
| 71 | + break; |
| 72 | + } |
| 73 | + } |
| 74 | + } |
| 75 | + } |
| 76 | + out.sort(); |
| 77 | + return out; |
| 78 | +} |
| 79 | + |
| 80 | +function uniq(arr) { |
| 81 | + const out = []; |
| 82 | + for (const x of arr) { |
| 83 | + if (!x) continue; |
| 84 | + if (!out.includes(x)) out.push(x); |
| 85 | + } |
| 86 | + return out; |
| 87 | +} |
| 88 | + |
| 89 | +function parseArgs(argv) { |
| 90 | + const out = { dir: process.cwd() }; |
| 91 | + for (let i = 2; i < argv.length; i++) { |
| 92 | + const a = argv[i]; |
| 93 | + if (a === "--dir" || a === "--pluginDir") { |
| 94 | + out.dir = path.resolve(argv[++i] || "."); |
| 95 | + continue; |
| 96 | + } |
| 97 | + } |
| 98 | + return out; |
| 99 | +} |
| 100 | + |
| 101 | +const args = parseArgs(process.argv); |
| 102 | +const pluginDir = args.dir; |
| 103 | +const pkgPath = path.join(pluginDir, "package.json"); |
| 104 | + |
| 105 | +if (!exists(pkgPath)) { |
| 106 | + console.error(`[wiring] ERROR: missing package.json in ${pluginDir}`); |
| 107 | + process.exit(2); |
| 108 | +} |
| 109 | + |
| 110 | +let pkg; |
| 111 | +try { |
| 112 | + pkg = JSON.parse(readText(pkgPath)); |
| 113 | +} catch (e) { |
| 114 | + console.error(`[wiring] ERROR: invalid package.json (${pkgPath}): ${e?.message || e}`); |
| 115 | + process.exit(2); |
| 116 | +} |
| 117 | + |
| 118 | +const cap = typeof pkg.capacitor === "object" && pkg.capacitor ? pkg.capacitor : {}; |
| 119 | +const supportsAndroid = typeof cap.android === "object" && cap.android; |
| 120 | +const supportsIos = typeof cap.ios === "object" && cap.ios; |
| 121 | + |
| 122 | +// Not a Capacitor plugin package (e.g. meta/workspace package). |
| 123 | +// We only enforce wiring rules for actual plugin packages declaring a `capacitor` config. |
| 124 | +if (!supportsAndroid && !supportsIos) { |
| 125 | + process.exit(0); |
| 126 | +} |
| 127 | + |
| 128 | +// ---------------- JS (registerPlugin) ---------------- |
| 129 | +const jsSrcDir = path.join(pluginDir, "src"); |
| 130 | +let jsName = ""; |
| 131 | +if (exists(jsSrcDir)) { |
| 132 | + const jsFiles = walkFiles(jsSrcDir, [".ts", ".js"]); |
| 133 | + const reRegister = /registerPlugin(?:<[^>]*>)?\(\s*['"]([^'"]+)['"]/; |
| 134 | + for (const f of jsFiles) { |
| 135 | + const m = reRegister.exec(readText(f)); |
| 136 | + if (m) { |
| 137 | + jsName = m[1]; |
| 138 | + break; |
| 139 | + } |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +// ---------------- Android (@CapacitorPlugin) ---------------- |
| 144 | +let androidNames = []; |
| 145 | +if (supportsAndroid) { |
| 146 | + const androidMain = path.join(pluginDir, "android", "src", "main"); |
| 147 | + const files = walkFiles(androidMain, [".java", ".kt"]); |
| 148 | + const foundAnnotations = []; |
| 149 | + for (const f of files) { |
| 150 | + const txt = readText(f); |
| 151 | + if (!txt.includes("@CapacitorPlugin")) continue; |
| 152 | + foundAnnotations.push(f); |
| 153 | + const m = |
| 154 | + /@CapacitorPlugin\(\s*name\s*=\s*"([^"]+)"/.exec(txt) || |
| 155 | + /@CapacitorPlugin\(\s*name\s*=\s*([A-Za-z0-9_]+)\b/.exec(txt); |
| 156 | + if (m) androidNames.push(m[1]); |
| 157 | + } |
| 158 | + androidNames = uniq(androidNames); |
| 159 | + |
| 160 | + // Enforce explicit name attribute to prevent silent class-name drift. |
| 161 | + if (foundAnnotations.length && !androidNames.length) { |
| 162 | + console.error( |
| 163 | + `[wiring] ERROR: Android has @CapacitorPlugin but none specify name = \"...\". Add an explicit name to the plugin class.` |
| 164 | + ); |
| 165 | + process.exit(1); |
| 166 | + } |
| 167 | +} |
| 168 | + |
| 169 | +// ---------------- iOS (jsName) ---------------- |
| 170 | +let iosJsNames = []; |
| 171 | +if (supportsIos) { |
| 172 | + const iosDir = path.join(pluginDir, "ios"); |
| 173 | + const scanRoot = exists(path.join(iosDir, "Sources")) ? path.join(iosDir, "Sources") : iosDir; |
| 174 | + const swiftFiles = walkFiles(scanRoot, [".swift"]); |
| 175 | + const reJsName = /\bjsName\s*=\s*"([^"]+)"/g; |
| 176 | + for (const f of swiftFiles) { |
| 177 | + const txt = readText(f); |
| 178 | + if (!txt.includes("jsName")) continue; |
| 179 | + let m; |
| 180 | + while ((m = reJsName.exec(txt))) iosJsNames.push(m[1]); |
| 181 | + } |
| 182 | + iosJsNames = uniq(iosJsNames); |
| 183 | +} |
| 184 | + |
| 185 | +// ---------------- Podspec/SPM ---------------- |
| 186 | +function parsePodspecName(podspecPath) { |
| 187 | + const txt = readText(podspecPath); |
| 188 | + const m = /\bs\.name\s*=\s*'([^']+)'/.exec(txt); |
| 189 | + return m ? m[1] : ""; |
| 190 | +} |
| 191 | + |
| 192 | +function parseSpmNames(packageSwiftPath) { |
| 193 | + const txt = readText(packageSwiftPath); |
| 194 | + const pkg = /Package\(\s*name\s*:\s*"([^"]+)"/.exec(txt)?.[1] || ""; |
| 195 | + const libs = []; |
| 196 | + const reLib = /\.library\(\s*name\s*:\s*"([^"]+)"/g; |
| 197 | + let m; |
| 198 | + while ((m = reLib.exec(txt))) libs.push(m[1]); |
| 199 | + return { pkgName: pkg, libNames: uniq(libs) }; |
| 200 | +} |
| 201 | + |
| 202 | +// ---------------- Validate ---------------- |
| 203 | +const errors = []; |
| 204 | + |
| 205 | +if (!jsName) { |
| 206 | + errors.push("JS: no registerPlugin('...') found under src/"); |
| 207 | +} |
| 208 | + |
| 209 | +if (supportsAndroid) { |
| 210 | + if (!androidNames.length) errors.push("Android: missing @CapacitorPlugin(name = \"...\")"); |
| 211 | + if (jsName && androidNames.length && androidNames.some((n) => n !== jsName)) { |
| 212 | + errors.push(`Android: @CapacitorPlugin(name)=${JSON.stringify(androidNames)} != JS registerPlugin=${jsName}`); |
| 213 | + } |
| 214 | +} |
| 215 | + |
| 216 | +if (supportsIos) { |
| 217 | + if (!iosJsNames.length) errors.push('iOS: missing jsName = "..." in Swift sources'); |
| 218 | + if (jsName && iosJsNames.length && iosJsNames.some((n) => n !== jsName)) { |
| 219 | + errors.push(`iOS: jsName=${JSON.stringify(iosJsNames)} != JS registerPlugin=${jsName}`); |
| 220 | + } |
| 221 | + |
| 222 | + const podspecs = fs |
| 223 | + .readdirSync(pluginDir, { withFileTypes: true }) |
| 224 | + .filter((e) => e.isFile() && e.name.endsWith(".podspec")) |
| 225 | + .map((e) => path.join(pluginDir, e.name)) |
| 226 | + .sort(); |
| 227 | + if (!podspecs.length) errors.push("iOS: missing *.podspec at plugin root"); |
| 228 | + if (podspecs.length > 1) errors.push(`iOS: multiple podspecs at plugin root: ${podspecs.map((p) => path.basename(p))}`); |
| 229 | + |
| 230 | + const pkgSwift = path.join(pluginDir, "Package.swift"); |
| 231 | + if (!exists(pkgSwift)) { |
| 232 | + errors.push("iOS: missing Package.swift at plugin root"); |
| 233 | + } else if (podspecs.length) { |
| 234 | + const podName = parsePodspecName(podspecs[0]); |
| 235 | + const { pkgName, libNames } = parseSpmNames(pkgSwift); |
| 236 | + if (!podName) errors.push("Podspec: missing s.name = '...'"); |
| 237 | + if (!pkgName) errors.push('SPM: missing Package(name: "...")'); |
| 238 | + if (podName && pkgName && podName !== pkgName) { |
| 239 | + errors.push(`Podspec: s.name=${podName} != Package(name)=${pkgName}`); |
| 240 | + } |
| 241 | + if (pkgName && libNames.length && !libNames.includes(pkgName)) { |
| 242 | + errors.push(`SPM: Package(name)=${pkgName} not present in .library(name) list ${JSON.stringify(libNames)}`); |
| 243 | + } |
| 244 | + } |
| 245 | +} |
| 246 | + |
| 247 | +if (errors.length) { |
| 248 | + const relDir = path.relative(process.cwd(), pluginDir) || "."; |
| 249 | + console.error(`[wiring] FAIL in ${relDir}`); |
| 250 | + for (const e of errors) console.error(`- ${e}`); |
| 251 | + process.exit(1); |
| 252 | +} |
| 253 | + |
| 254 | +process.exit(0); |
0 commit comments