xref: /expo/tools/src/Packages.ts (revision 43fd9ed3)
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