1import JsonFile from '@expo/json-file';
2import resolveFrom from 'resolve-from';
3import semver from 'semver';
4
5import { getCombinedKnownVersionsAsync } from './getVersionedPackages';
6
7const debug = require('debug')('expo:doctor:dependencies:getMissingPackages') as typeof console.log;
8
9export type ResolvedPackage = {
10  /** Module ID pointing to the library `package.json`. */
11  file: string;
12  /** NPM package name. */
13  pkg: string;
14  /** Required version range. */
15  version?: string;
16  /** If the dependency should be installed as a `devDependency` */
17  dev?: boolean;
18};
19
20/** Given a set of required packages, this method returns a list of missing packages. */
21export function collectMissingPackages(
22  projectRoot: string,
23  requiredPackages: ResolvedPackage[]
24): {
25  missing: ResolvedPackage[];
26  resolutions: Record<string, string>;
27} {
28  const resolutions: Record<string, string> = {};
29
30  const missingPackages = requiredPackages.filter((p) => {
31    const resolved = resolveFrom.silent(projectRoot, p.file);
32    if (!resolved || !versionSatisfiesRequiredPackage(resolved, p)) {
33      return true;
34    }
35    resolutions[p.pkg] = resolved;
36    return false;
37  });
38
39  return { missing: missingPackages, resolutions };
40}
41
42export function versionSatisfiesRequiredPackage(
43  packageJsonFilePath: string,
44  resolvedPackage: Pick<ResolvedPackage, 'version' | 'pkg'>
45): boolean {
46  // If the version is specified, check that it satisfies the installed version.
47  if (!resolvedPackage.version) {
48    debug(`Required package "${resolvedPackage.pkg}" found (no version constraint specified).`);
49    return true;
50  }
51
52  const pkgJson = JsonFile.read(packageJsonFilePath);
53  if (
54    // package.json has version.
55    typeof pkgJson.version === 'string' &&
56    // semver satisfaction.
57    semver.satisfies(pkgJson.version, resolvedPackage.version)
58  ) {
59    return true;
60  }
61  debug(
62    `Installed package "${resolvedPackage.pkg}" does not satisfy version constraint "${resolvedPackage.version}" (version: "${pkgJson.version}")`
63  );
64  return false;
65}
66
67/**
68 * Collect missing packages given a list of required packages.
69 * Any missing packages will be versioned to the known versions for the current SDK.
70 *
71 * @param projectRoot
72 * @param props.requiredPackages list of required packages to check for
73 * @returns list of missing packages and resolutions to existing packages.
74 */
75export async function getMissingPackagesAsync(
76  projectRoot: string,
77  {
78    sdkVersion,
79    requiredPackages,
80  }: {
81    sdkVersion?: string;
82    requiredPackages: ResolvedPackage[];
83  }
84): Promise<{
85  missing: ResolvedPackage[];
86  resolutions: Record<string, string>;
87}> {
88  const results = collectMissingPackages(projectRoot, requiredPackages);
89  if (!results.missing.length) {
90    return results;
91  }
92
93  // Ensure the versions are right for the SDK that the project is currently using.
94  await mutatePackagesWithKnownVersionsAsync(projectRoot, sdkVersion, results.missing);
95
96  return results;
97}
98
99export async function mutatePackagesWithKnownVersionsAsync(
100  projectRoot: string,
101  sdkVersion: string | undefined,
102  packages: ResolvedPackage[]
103) {
104  // Ensure the versions are right for the SDK that the project is currently using.
105  const relatedPackages = await getCombinedKnownVersionsAsync({ projectRoot, sdkVersion });
106  for (const pkg of packages) {
107    if (
108      // Only use the SDK versions if the package does not already have a hardcoded version.
109      // We do this because some packages have API coded into the CLI which expects an exact version.
110      !pkg.version &&
111      pkg.pkg in relatedPackages
112    ) {
113      pkg.version = relatedPackages[pkg.pkg];
114    }
115  }
116  return packages;
117}
118