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