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