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