1import fs from 'fs-extra'; 2import glob from 'glob-promise'; 3import path from 'path'; 4 5import { Podspec, readPodspecAsync } from './CocoaPods'; 6import * as Directories from './Directories'; 7import * as Npm from './Npm'; 8import AndroidUnversionablePackages from './versioning/android/unversionablePackages.json'; 9import IosUnversionablePackages from './versioning/ios/unversionablePackages.json'; 10 11const ANDROID_DIR = Directories.getAndroidDir(); 12const IOS_DIR = Directories.getIosDir(); 13const PACKAGES_DIR = Directories.getPackagesDir(); 14 15/** 16 * Cached list of packages or `null` if they haven't been loaded yet. See `getListOfPackagesAsync`. 17 */ 18let cachedPackages: Package[] | null = null; 19 20/** 21 * An object representing `package.json` structure. 22 */ 23export type PackageJson = { 24 name: string; 25 version: string; 26 scripts: Record<string, string>; 27 gitHead?: string; 28 [key: string]: unknown; 29}; 30 31/** 32 * Type of package's dependency returned by `getDependencies`. 33 */ 34export type PackageDependency = { 35 name: string; 36 group: string; 37 versionRange: string; 38}; 39 40/** 41 * Union with possible platform names. 42 */ 43type Platform = 'ios' | 'android' | 'web'; 44 45/** 46 * Type representing `expo-modules.config.json` structure. 47 */ 48export type ExpoModuleConfig = { 49 name: string; 50 platforms: Platform[]; 51 ios?: { 52 subdirectory?: string; 53 podName?: string; 54 podspecPath?: string; 55 }; 56 android?: { 57 subdirectory?: string; 58 }; 59}; 60 61/** 62 * Represents a package in the monorepo. 63 */ 64export class Package { 65 path: string; 66 packageJson: PackageJson; 67 expoModuleConfig: ExpoModuleConfig; 68 packageView?: Npm.PackageViewType | null; 69 70 constructor(rootPath: string, packageJson?: PackageJson) { 71 this.path = rootPath; 72 this.packageJson = packageJson || require(path.join(rootPath, 'package.json')); 73 this.expoModuleConfig = readExpoModuleConfigJson(rootPath); 74 } 75 76 get hasPlugin(): boolean { 77 return fs.pathExistsSync(path.join(this.path, 'plugin')); 78 } 79 80 get packageName(): string { 81 return this.packageJson.name; 82 } 83 84 get packageVersion(): string { 85 return this.packageJson.version; 86 } 87 88 get packageSlug(): string { 89 return (this.expoModuleConfig && this.expoModuleConfig.name) || this.packageName; 90 } 91 92 get scripts(): { [key: string]: string } { 93 return this.packageJson.scripts || {}; 94 } 95 96 get podspecPath(): string | null { 97 if (this.expoModuleConfig?.ios?.podspecPath) { 98 return this.expoModuleConfig.ios.podspecPath; 99 } 100 101 const iosConfig = { 102 subdirectory: 'ios', 103 ...(this.expoModuleConfig?.ios ?? {}), 104 }; 105 106 // Obtain podspecName by looking for podspecs 107 const podspecPaths = glob.sync('*.podspec', { 108 cwd: path.join(this.path, iosConfig.subdirectory), 109 }); 110 111 if (!podspecPaths || podspecPaths.length === 0) { 112 return null; 113 } 114 return path.join(iosConfig.subdirectory, podspecPaths[0]); 115 } 116 117 get podspecName(): string | null { 118 const iosConfig = { 119 subdirectory: 'ios', 120 ...(this.expoModuleConfig?.ios ?? {}), 121 }; 122 123 // 'ios.podName' is actually not used anywhere in our modules, but let's have the same logic as react-native-unimodules script. 124 if ('podName' in iosConfig) { 125 return iosConfig.podName as string; 126 } 127 128 const podspecPath = this.podspecPath; 129 if (!podspecPath) { 130 return null; 131 } 132 return path.basename(podspecPath, '.podspec'); 133 } 134 135 get iosSubdirectory(): string { 136 return this.expoModuleConfig?.ios?.subdirectory ?? 'ios'; 137 } 138 139 get androidSubdirectory(): string { 140 return this.expoModuleConfig?.android?.subdirectory ?? 'android'; 141 } 142 143 get androidPackageName(): string | null { 144 if (!this.isSupportedOnPlatform('android')) { 145 return null; 146 } 147 const buildGradle = fs.readFileSync( 148 path.join(this.path, this.androidSubdirectory, 'build.gradle'), 149 'utf8' 150 ); 151 const match = buildGradle.match(/^group ?= ?'([\w.]+)'\n/m); 152 return match?.[1] ?? null; 153 } 154 155 get changelogPath(): string { 156 return path.join(this.path, 'CHANGELOG.md'); 157 } 158 159 isExpoModule() { 160 return !!this.expoModuleConfig; 161 } 162 163 isSupportedOnPlatform(platform: 'ios' | 'android'): boolean { 164 if (this.expoModuleConfig && !fs.existsSync(path.join(this.path, 'react-native.config.js'))) { 165 // check platform support from expo autolinking but not rn-cli linking which is not platform aware 166 return this.expoModuleConfig.platforms?.includes(platform) ?? false; 167 } else if (platform === 'android') { 168 return fs.existsSync(path.join(this.path, this.androidSubdirectory, 'build.gradle')); 169 } else if (platform === 'ios') { 170 return ( 171 fs.existsSync(path.join(this.path, this.iosSubdirectory)) && 172 fs 173 .readdirSync(path.join(this.path, this.iosSubdirectory)) 174 .some((path) => path.endsWith('.podspec')) 175 ); 176 } 177 return false; 178 } 179 180 isIncludedInExpoClientOnPlatform(platform: 'ios' | 'android'): boolean { 181 if (platform === 'ios') { 182 // On iOS we can easily check whether the package is included in Expo client by checking if it is installed by Cocoapods. 183 const { podspecName } = this; 184 return ( 185 podspecName != null && 186 fs.pathExistsSync(path.join(IOS_DIR, 'Pods', 'Headers', 'Public', podspecName)) 187 ); 188 } else if (platform === 'android') { 189 // On Android we need to read settings.gradle file 190 const settingsGradle = fs.readFileSync(path.join(ANDROID_DIR, 'settings.gradle'), 'utf8'); 191 const match = settingsGradle.search( 192 new RegExp( 193 `useExpoModules\\([^\\)]+exclude\\s*:\\s*\\[[^\\]]*'${this.packageName}'[^\\]]*\\][^\\)]+\\)` 194 ) 195 ); 196 // this is somewhat brittle so we do a quick-and-dirty sanity check: 197 // 'expo-in-app-purchases' should never be included so if we don't find a match 198 // for that package, something is wrong. 199 if (this.packageName === 'expo-in-app-purchases' && match === -1) { 200 throw new Error( 201 "'isIncludedInExpoClientOnPlatform' is not behaving correctly, please check expoview/build.gradle format" 202 ); 203 } 204 return match === -1; 205 } 206 throw new Error( 207 `'isIncludedInExpoClientOnPlatform' is not supported on '${platform}' platform yet.` 208 ); 209 } 210 211 isVersionableOnPlatform(platform: 'ios' | 'android'): boolean { 212 if (platform === 'ios') { 213 return this.podspecName != null && !IosUnversionablePackages.includes(this.packageName); 214 } else if (platform === 'android') { 215 return !AndroidUnversionablePackages.includes(this.packageName); 216 } 217 throw new Error(`'isVersionableOnPlatform' is not supported on '${platform}' platform yet.`); 218 } 219 220 async getPackageViewAsync(): Promise<Npm.PackageViewType | null> { 221 if (this.packageView !== undefined) { 222 return this.packageView; 223 } 224 return await Npm.getPackageViewAsync(this.packageName, this.packageVersion); 225 } 226 227 getDependencies(includeDev: boolean = false): PackageDependency[] { 228 const depsGroups = ['dependencies', 'peerDependencies', 'optionalDependencies']; 229 230 if (includeDev) { 231 depsGroups.push('devDependencies'); 232 } 233 234 const dependencies = depsGroups.map((group) => { 235 const deps = this.packageJson[group] as Record<string, string>; 236 237 return !deps 238 ? [] 239 : Object.entries(deps).map(([name, versionRange]) => { 240 return { 241 name, 242 group, 243 versionRange: versionRange as string, 244 }; 245 }); 246 }); 247 return ([] as PackageDependency[]).concat(...dependencies); 248 } 249 250 dependsOn(packageName: string): boolean { 251 return this.getDependencies().some((dep) => dep.name === packageName); 252 } 253 254 /** 255 * Iterates through dist tags returned by npm to determine an array of tags to which given version is bound. 256 */ 257 async getDistTagsAsync(version: string = this.packageVersion): Promise<string[]> { 258 const pkgView = await this.getPackageViewAsync(); 259 const distTags = pkgView?.['dist-tags'] ?? {}; 260 return Object.keys(distTags).filter((tag) => distTags[tag] === version); 261 } 262 263 /** 264 * Checks whether the package depends on a local pod with given name. 265 */ 266 async hasLocalPodDependencyAsync(podName?: string | null): Promise<boolean> { 267 if (!podName) { 268 return false; 269 } 270 const podspecPath = path.join(this.path, 'ios/Pods/Local Podspecs', `${podName}.podspec.json`); 271 return await fs.pathExists(podspecPath); 272 } 273 274 /** 275 * Checks whether package has its own changelog file. 276 */ 277 async hasChangelogAsync(): Promise<boolean> { 278 return fs.pathExists(this.changelogPath); 279 } 280 281 /** 282 * Checks whether package has any native code (iOS, Android, C++). 283 */ 284 async isNativeModuleAsync(): Promise<boolean> { 285 const dirs = ['ios', 'android', 'cpp'].map((dir) => path.join(this.path, dir)); 286 for (const dir of dirs) { 287 if (await fs.pathExists(dir)) { 288 return true; 289 } 290 } 291 return false; 292 } 293 294 /** 295 * Checks whether the package contains native unit tests on the given platform. 296 */ 297 async hasNativeTestsAsync(platform: Platform): Promise<boolean> { 298 if (platform === 'android') { 299 return ( 300 fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/test')) || 301 fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/androidTest')) 302 ); 303 } 304 if (platform === 'ios') { 305 return ( 306 this.isSupportedOnPlatform(platform) && 307 !!this.podspecPath && 308 fs.readFileSync(path.join(this.path, this.podspecPath), 'utf8').includes('test_spec') 309 ); 310 } 311 // TODO(tsapeta): Support web. 312 throw new Error(`"hasNativeTestsAsync" for platform "${platform}" is not implemented yet.`); 313 } 314 315 /** 316 * Checks whether package contains native instrumentation tests for Android. 317 */ 318 async hasNativeInstrumentationTestsAsync(platform: Platform): Promise<boolean> { 319 if (platform === 'android') { 320 return fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/androidTest')); 321 } 322 return false; 323 } 324 325 /** 326 * Reads the podspec and returns it in JSON format 327 * or `null` if the package doesn't have a podspec. 328 */ 329 async getPodspecAsync(): Promise<Podspec | null> { 330 if (!this.podspecPath) { 331 return null; 332 } 333 const podspecPath = path.join(this.path, this.podspecPath); 334 return await readPodspecAsync(podspecPath); 335 } 336} 337 338/** 339 * Resolves to a Package instance if the package with given name exists in the repository. 340 */ 341export function getPackageByName(packageName: string): Package | null { 342 const packageJsonPath = pathToLocalPackageJson(packageName); 343 try { 344 const packageJson = require(packageJsonPath); 345 return new Package(path.dirname(packageJsonPath), packageJson); 346 } catch { 347 return null; 348 } 349} 350 351/** 352 * Resolves to an array of Package instances that represent Expo packages inside given directory. 353 */ 354export async function getListOfPackagesAsync(): Promise<Package[]> { 355 if (!cachedPackages) { 356 const paths = await glob('**/package.json', { 357 cwd: PACKAGES_DIR, 358 ignore: ['**/example/**', '**/node_modules/**'], 359 }); 360 cachedPackages = paths.map((packageJsonPath) => { 361 const fullPackageJsonPath = path.join(PACKAGES_DIR, packageJsonPath); 362 const packagePath = path.dirname(fullPackageJsonPath); 363 const packageJson = require(fullPackageJsonPath); 364 365 return new Package(packagePath, packageJson); 366 }); 367 } 368 return cachedPackages; 369} 370 371function readExpoModuleConfigJson(dir: string) { 372 const expoModuleConfigJsonPath = path.join(dir, 'expo-module.config.json'); 373 const expoModuleConfigJsonExists = fs.existsSync(expoModuleConfigJsonPath); 374 const unimoduleJsonPath = path.join(dir, 'unimodule.json'); 375 try { 376 return require(expoModuleConfigJsonExists ? expoModuleConfigJsonPath : unimoduleJsonPath); 377 } catch (error) { 378 return null; 379 } 380} 381 382function pathToLocalPackageJson(packageName: string): string { 383 return path.join(PACKAGES_DIR, packageName, 'package.json'); 384} 385