import fs from 'fs-extra'; import glob from 'glob-promise'; import path from 'path'; import { Podspec, readPodspecAsync } from './CocoaPods'; import * as Directories from './Directories'; import * as Npm from './Npm'; import AndroidUnversionablePackages from './versioning/android/unversionablePackages.json'; import IosUnversionablePackages from './versioning/ios/unversionablePackages.json'; const ANDROID_DIR = Directories.getAndroidDir(); const IOS_DIR = Directories.getIosDir(); const PACKAGES_DIR = Directories.getPackagesDir(); /** * Cached list of packages or `null` if they haven't been loaded yet. See `getListOfPackagesAsync`. */ let cachedPackages: Package[] | null = null; export interface CodegenConfigLibrary { name: string; type: 'modules' | 'components'; jsSrcsDir: string; } export enum DependencyKind { Normal = 'dependencies', Dev = 'devDependencies', Peer = 'peerDependencies', Optional = 'optionalDependencies', } export const DefaultDependencyKind = [DependencyKind.Normal, DependencyKind.Dev]; /** * An object representing `package.json` structure. */ export type PackageJson = { name: string; version: string; scripts: Record; gitHead?: string; codegenConfig?: { libraries: CodegenConfigLibrary[]; }; dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; [key: string]: unknown; }; /** * Type of package's dependency returned by `getDependencies`. */ export type PackageDependency = { name: string; kind: DependencyKind; versionRange: string; }; /** * Union with possible platform names. */ type Platform = 'ios' | 'android' | 'web'; /** * Type representing `expo-modules.config.json` structure. */ export type ExpoModuleConfig = { name: string; platforms: Platform[]; ios?: { subdirectory?: string; podName?: string; podspecPath?: string; }; android?: { subdirectory?: string; }; }; /** * Represents a package in the monorepo. */ export class Package { path: string; packageJson: PackageJson; expoModuleConfig: ExpoModuleConfig; packageView?: Npm.PackageViewType | null; constructor(rootPath: string, packageJson?: PackageJson) { this.path = rootPath; this.packageJson = packageJson || require(path.join(rootPath, 'package.json')); this.expoModuleConfig = readExpoModuleConfigJson(rootPath); } get hasPlugin(): boolean { return fs.pathExistsSync(path.join(this.path, 'plugin')); } get packageName(): string { return this.packageJson.name; } get packageVersion(): string { return this.packageJson.version; } get packageSlug(): string { return (this.expoModuleConfig && this.expoModuleConfig.name) || this.packageName; } get scripts(): { [key: string]: string } { return this.packageJson.scripts || {}; } get podspecPath(): string | null { if (this.expoModuleConfig?.ios?.podspecPath) { return this.expoModuleConfig.ios.podspecPath; } // Obtain podspecName by looking for podspecs in both package's root directory and ios subdirectory. const [podspecPath] = glob.sync(`{*,${this.iosSubdirectory}/*}.podspec`, { cwd: this.path, }); return podspecPath || null; } get podspecName(): string | null { const iosConfig = { subdirectory: 'ios', ...(this.expoModuleConfig?.ios ?? {}), }; // 'ios.podName' is actually not used anywhere in our modules, but let's have the same logic as react-native-unimodules script. if ('podName' in iosConfig) { return iosConfig.podName as string; } const podspecPath = this.podspecPath; if (!podspecPath) { return null; } return path.basename(podspecPath, '.podspec'); } get iosSubdirectory(): string { return this.expoModuleConfig?.ios?.subdirectory ?? 'ios'; } get androidSubdirectory(): string { return this.expoModuleConfig?.android?.subdirectory ?? 'android'; } get androidPackageName(): string | null { if (!this.isSupportedOnPlatform('android')) { return null; } const buildGradle = fs.readFileSync( path.join(this.path, this.androidSubdirectory, 'build.gradle'), 'utf8' ); const match = buildGradle.match(/^group ?= ?'([\w.]+)'\n/m); return match?.[1] ?? null; } get androidPackageNamespace(): string | null { if (!this.isSupportedOnPlatform('android')) { return null; } const buildGradle = fs.readFileSync( path.join(this.path, this.androidSubdirectory, 'build.gradle'), 'utf8' ); const match = buildGradle.match(/^\s+namespace\s*=?\s*['"]([\w.]+)['"]/m); return match?.[1] ?? null; } get changelogPath(): string { return path.join(this.path, 'CHANGELOG.md'); } isExpoModule() { return !!this.expoModuleConfig; } containsPodspecFile() { return [ ...fs.readdirSync(this.path), ...fs.readdirSync(path.join(this.path, this.iosSubdirectory)), ].some((path) => path.endsWith('.podspec')); } isSupportedOnPlatform(platform: 'ios' | 'android'): boolean { if (this.expoModuleConfig && !fs.existsSync(path.join(this.path, 'react-native.config.js'))) { // check platform support from expo autolinking but not rn-cli linking which is not platform aware return this.expoModuleConfig.platforms?.includes(platform) ?? false; } else if (platform === 'android') { return fs.existsSync(path.join(this.path, this.androidSubdirectory, 'build.gradle')); } else if (platform === 'ios') { return ( fs.existsSync(path.join(this.path, this.iosSubdirectory)) && this.containsPodspecFile() ); } return false; } isIncludedInExpoClientOnPlatform(platform: 'ios' | 'android'): boolean { if (platform === 'ios') { // On iOS we can easily check whether the package is included in Expo client by checking if it is installed by Cocoapods. const { podspecName } = this; return ( podspecName != null && fs.pathExistsSync(path.join(IOS_DIR, 'Pods', 'Headers', 'Public', podspecName)) ); } else if (platform === 'android') { // On Android we need to read settings.gradle file const settingsGradle = fs.readFileSync(path.join(ANDROID_DIR, 'settings.gradle'), 'utf8'); const match = settingsGradle.search( new RegExp( `useExpoModules\\([^\\)]+exclude\\s*:\\s*\\[[^\\]]*'${this.packageName}'[^\\]]*\\][^\\)]+\\)` ) ); // this is somewhat brittle so we do a quick-and-dirty sanity check: // 'expo-in-app-purchases' should never be included so if we don't find a match // for that package, something is wrong. if (this.packageName === 'expo-in-app-purchases' && match === -1) { throw new Error( "'isIncludedInExpoClientOnPlatform' is not behaving correctly, please check android/settings.gradle format" ); } return match === -1; } throw new Error( `'isIncludedInExpoClientOnPlatform' is not supported on '${platform}' platform yet.` ); } isVersionableOnPlatform(platform: 'ios' | 'android'): boolean { if (platform === 'ios') { return this.podspecName != null && !IosUnversionablePackages.includes(this.packageName); } else if (platform === 'android') { return !AndroidUnversionablePackages.includes(this.packageName); } throw new Error(`'isVersionableOnPlatform' is not supported on '${platform}' platform yet.`); } async getPackageViewAsync(): Promise { if (this.packageView !== undefined) { return this.packageView; } return await Npm.getPackageViewAsync(this.packageName, this.packageVersion); } getDependencies(kinds: DependencyKind[] = [DependencyKind.Normal]): PackageDependency[] { const dependencies = kinds.map((kind) => { const deps = this.packageJson[kind]; return !deps ? [] : Object.entries(deps).map(([name, versionRange]) => { return { name, kind, versionRange, }; }); }); return ([] as PackageDependency[]).concat(...dependencies); } dependsOn(packageName: string): boolean { return this.getDependencies().some((dep) => dep.name === packageName); } /** * Iterates through dist tags returned by npm to determine an array of tags to which given version is bound. */ async getDistTagsAsync(version: string = this.packageVersion): Promise { const pkgView = await this.getPackageViewAsync(); const distTags = pkgView?.['dist-tags'] ?? {}; return Object.keys(distTags).filter((tag) => distTags[tag] === version); } /** * Checks whether the package depends on a local pod with given name. */ async hasLocalPodDependencyAsync(podName?: string | null): Promise { if (!podName) { return false; } const podspecPath = path.join(this.path, 'ios/Pods/Local Podspecs', `${podName}.podspec.json`); return await fs.pathExists(podspecPath); } /** * Checks whether package has its own changelog file. */ async hasChangelogAsync(): Promise { return fs.pathExists(this.changelogPath); } /** * Checks whether package has any native code (iOS, Android, C++). */ async isNativeModuleAsync(): Promise { const dirs = ['ios', 'android', 'cpp'].map((dir) => path.join(this.path, dir)); for (const dir of dirs) { if (await fs.pathExists(dir)) { return true; } } return false; } /** * Checks whether the package contains native unit tests on the given platform. */ async hasNativeTestsAsync(platform: Platform): Promise { if (platform === 'android') { return ( fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/test')) || fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/androidTest')) ); } if (platform === 'ios') { return ( this.isSupportedOnPlatform(platform) && !!this.podspecPath && fs.readFileSync(path.join(this.path, this.podspecPath), 'utf8').includes('test_spec') ); } // TODO(tsapeta): Support web. throw new Error(`"hasNativeTestsAsync" for platform "${platform}" is not implemented yet.`); } /** * Checks whether package contains native instrumentation tests for Android. */ async hasNativeInstrumentationTestsAsync(platform: Platform): Promise { if (platform === 'android') { return fs.pathExists(path.join(this.path, this.androidSubdirectory, 'src/androidTest')); } return false; } /** * Reads the podspec and returns it in JSON format * or `null` if the package doesn't have a podspec. */ async getPodspecAsync(): Promise { if (!this.podspecPath) { return null; } const podspecPath = path.join(this.path, this.podspecPath); return await readPodspecAsync(podspecPath); } } /** * Resolves to a Package instance if the package with given name exists in the repository. */ export function getPackageByName(packageName: string): Package | null { const packageJsonPath = pathToLocalPackageJson(packageName); try { const packageJson = require(packageJsonPath); return new Package(path.dirname(packageJsonPath), packageJson); } catch { return null; } } /** * Resolves to an array of Package instances that represent Expo packages inside given directory. */ export async function getListOfPackagesAsync(): Promise { if (!cachedPackages) { const paths = await glob('**/package.json', { cwd: PACKAGES_DIR, ignore: ['**/example/**', '**/node_modules/**', '**/__tests__/**', '**/__mocks__/**'], }); cachedPackages = paths .map((packageJsonPath) => { const fullPackageJsonPath = path.join(PACKAGES_DIR, packageJsonPath); const packagePath = path.dirname(fullPackageJsonPath); const packageJson = require(fullPackageJsonPath); return new Package(packagePath, packageJson); }) .filter((pkg) => !!pkg.packageName); } return cachedPackages; } function readExpoModuleConfigJson(dir: string) { const expoModuleConfigJsonPath = path.join(dir, 'expo-module.config.json'); const expoModuleConfigJsonExists = fs.existsSync(expoModuleConfigJsonPath); const unimoduleJsonPath = path.join(dir, 'unimodule.json'); try { return require(expoModuleConfigJsonExists ? expoModuleConfigJsonPath : unimoduleJsonPath); } catch { return null; } } function pathToLocalPackageJson(packageName: string): string { return path.join(PACKAGES_DIR, packageName, 'package.json'); }