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