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