1import { PackageJSONConfig } from '@expo/config';
2import npmPackageArg from 'npm-package-arg';
3
4import { getVersionedNativeModulesAsync } from './bundledNativeModules';
5import { getVersionsAsync, SDKVersion } from '../../../api/getVersions';
6import { Log } from '../../../log';
7import { env } from '../../../utils/env';
8import { CommandError } from '../../../utils/errors';
9
10const debug = require('debug')(
11  'expo:doctor:dependencies:getVersionedPackages'
12) as typeof console.log;
13
14export type DependencyList = Record<string, string>;
15
16/** Adds `react-dom`, `react`, and `react-native` to the list of known package versions (`relatedPackages`) */
17function normalizeSdkVersionObject(version?: SDKVersion): Record<string, string> {
18  if (!version) {
19    return {};
20  }
21  const { relatedPackages, facebookReactVersion, facebookReactNativeVersion } = version;
22
23  const reactVersion = facebookReactVersion
24    ? {
25        react: facebookReactVersion,
26        'react-dom': facebookReactVersion,
27      }
28    : undefined;
29
30  return {
31    ...relatedPackages,
32    ...reactVersion,
33    'react-native': facebookReactNativeVersion,
34  };
35}
36
37/** Get the known versions for a given SDK, combines all sources. */
38export async function getCombinedKnownVersionsAsync({
39  projectRoot,
40  sdkVersion,
41  skipCache,
42}: {
43  projectRoot: string;
44  sdkVersion?: string;
45  skipCache?: boolean;
46}) {
47  const bundledNativeModules = sdkVersion
48    ? await getVersionedNativeModulesAsync(projectRoot, sdkVersion)
49    : {};
50  const versionsForSdk = await getRemoteVersionsForSdkAsync({ sdkVersion, skipCache });
51  return {
52    ...versionsForSdk,
53    ...bundledNativeModules,
54  };
55}
56
57/** @returns a key/value list of known dependencies and their version (including range). */
58export async function getRemoteVersionsForSdkAsync({
59  sdkVersion,
60  skipCache,
61}: { sdkVersion?: string; skipCache?: boolean } = {}): Promise<DependencyList> {
62  if (env.EXPO_OFFLINE) {
63    Log.warn('Dependency validation is unreliable in offline-mode');
64    return {};
65  }
66
67  try {
68    const { sdkVersions } = await getVersionsAsync({ skipCache });
69
70    // We only want versioned dependencies so skip if they cannot be found.
71    if (!sdkVersion || !(sdkVersion in sdkVersions)) {
72      debug(
73        `Skipping versioned dependencies because the SDK version is not found. (sdkVersion: ${sdkVersion}, available: ${Object.keys(
74          sdkVersions
75        ).join(', ')})`
76      );
77      return {};
78    }
79
80    const version = sdkVersions[sdkVersion as keyof typeof sdkVersions] as unknown as SDKVersion;
81
82    return normalizeSdkVersionObject(version);
83  } catch (error: any) {
84    if (error instanceof CommandError && error.code === 'OFFLINE') {
85      return getRemoteVersionsForSdkAsync({ sdkVersion, skipCache });
86    }
87    throw error;
88  }
89}
90
91/**
92 * Versions a list of `packages` against a given `sdkVersion` based on local and remote versioning resources.
93 *
94 * @param projectRoot
95 * @param param1
96 * @returns
97 */
98export async function getVersionedPackagesAsync(
99  projectRoot: string,
100  {
101    packages,
102    sdkVersion,
103    pkg,
104  }: {
105    /** List of npm packages to process. */
106    packages: string[];
107    /** Target SDK Version number to version the `packages` for. */
108    sdkVersion: string;
109    pkg: PackageJSONConfig;
110  }
111): Promise<{
112  packages: string[];
113  messages: string[];
114  excludedNativeModules: { name: string; bundledNativeVersion: string }[];
115}> {
116  const versionsForSdk = await getCombinedKnownVersionsAsync({
117    projectRoot,
118    sdkVersion,
119    skipCache: true,
120  });
121
122  let nativeModulesCount = 0;
123  let othersCount = 0;
124  const excludedNativeModules: { name: string; bundledNativeVersion: string }[] = [];
125
126  const versionedPackages = packages.map((arg) => {
127    const { name, type, raw } = npmPackageArg(arg);
128
129    if (['tag', 'version', 'range'].includes(type) && name && versionsForSdk[name]) {
130      // Unimodule packages from npm registry are modified to use the bundled version.
131      // Some packages have the recommended version listed in https://exp.host/--/api/v2/versions.
132      if (pkg?.expo?.install?.exclude?.includes(name)) {
133        othersCount++;
134        excludedNativeModules.push({ name, bundledNativeVersion: versionsForSdk[name] });
135        return raw;
136      }
137      nativeModulesCount++;
138      return `${name}@${versionsForSdk[name]}`;
139    } else {
140      // Other packages are passed through unmodified.
141      othersCount++;
142      return raw;
143    }
144  });
145
146  const messages = getOperationLog({
147    othersCount,
148    nativeModulesCount,
149    sdkVersion,
150  });
151
152  return {
153    packages: versionedPackages,
154    messages,
155    excludedNativeModules,
156  };
157}
158
159/** Craft a set of messages regarding the install operations. */
160export function getOperationLog({
161  nativeModulesCount,
162  sdkVersion,
163  othersCount,
164}: {
165  nativeModulesCount: number;
166  othersCount: number;
167  sdkVersion: string;
168}): string[] {
169  return [
170    nativeModulesCount > 0 &&
171      `${nativeModulesCount} SDK ${sdkVersion} compatible native ${
172        nativeModulesCount === 1 ? 'module' : 'modules'
173      }`,
174    othersCount > 0 && `${othersCount} other ${othersCount === 1 ? 'package' : 'packages'}`,
175  ].filter(Boolean) as string[];
176}
177