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