1import { ExpoConfig, getConfig } from '@expo/config';
2import chalk from 'chalk';
3import wrapAnsi from 'wrap-ansi';
4
5import { getMissingPackagesAsync, ResolvedPackage } from './getMissingPackages';
6import { installAsync } from '../../../install/installAsync';
7import * as Log from '../../../log';
8import { CommandError } from '../../../utils/errors';
9import { isInteractive } from '../../../utils/interactive';
10import { logNewSection } from '../../../utils/ora';
11import { confirmAsync } from '../../../utils/prompts';
12
13export async function ensureDependenciesAsync(
14  projectRoot: string,
15  {
16    exp = getConfig(projectRoot).exp,
17    requiredPackages,
18    warningMessage,
19    installMessage,
20    // Don't prompt in CI
21    skipPrompt = !isInteractive(),
22    isProjectMutable = isInteractive(),
23  }: {
24    exp?: ExpoConfig;
25    installMessage: string;
26    warningMessage: string;
27    requiredPackages: ResolvedPackage[];
28    skipPrompt?: boolean;
29    /** Project can be mutated in the current environment. */
30    isProjectMutable?: boolean;
31  }
32): Promise<boolean> {
33  const { missing } = await getMissingPackagesAsync(projectRoot, {
34    sdkVersion: exp.sdkVersion,
35    requiredPackages,
36  });
37  if (!missing.length) {
38    return true;
39  }
40
41  // Prompt to install or bail out...
42  const readableMissingPackages = missing
43    .map(({ pkg, version }) => (version ? [pkg, version].join('@') : pkg))
44    .join(', ');
45
46  let title = installMessage;
47
48  if (skipPrompt && !isProjectMutable) {
49    title += '\n\n';
50  } else {
51    let confirm = skipPrompt;
52    if (skipPrompt) {
53      // Automatically install packages without prompting.
54      Log.log(wrapForTerminal(title + ` Installing ${chalk.cyan(readableMissingPackages)}`));
55    } else {
56      confirm = await confirmAsync({
57        message: wrapForTerminal(
58          title + ` Would you like to install ${chalk.cyan(readableMissingPackages)}?`
59        ),
60        initial: true,
61      });
62    }
63
64    if (confirm) {
65      // Format with version if available.
66      const packages = missing.map(({ pkg, version }) =>
67        version ? [pkg, version].join('@') : pkg
68      );
69      // Install packages with versions
70      await installPackagesAsync(projectRoot, {
71        packages,
72      });
73      // Try again but skip prompting twice, simply fail if the packages didn't install correctly.
74      return await ensureDependenciesAsync(projectRoot, {
75        skipPrompt: true,
76        installMessage,
77        warningMessage,
78        requiredPackages,
79      });
80    }
81
82    // Reset the title so it doesn't print twice in interactive mode.
83    title = '';
84  }
85
86  const installCommand = createInstallCommand({
87    packages: missing,
88  });
89
90  const disableMessage = warningMessage;
91
92  const solution = `Please install ${chalk.bold(
93    readableMissingPackages
94  )} by running:\n\n  ${chalk.reset.bold(installCommand)}\n\n`;
95
96  // This prevents users from starting a misconfigured JS or TS project by default.
97  throw new CommandError(wrapForTerminal(title + solution + disableMessage + '\n'));
98}
99
100/**  Wrap long messages to fit smaller terminals. */
101function wrapForTerminal(message: string): string {
102  return wrapAnsi(message, process.stdout.columns || 80);
103}
104
105/** Create the bash install command from a given set of packages and settings. */
106export function createInstallCommand({
107  packages,
108}: {
109  packages: {
110    file: string;
111    pkg: string;
112    version?: string | undefined;
113  }[];
114}) {
115  return (
116    'npx expo install ' +
117    packages
118      .map(({ pkg, version }) => {
119        if (version) {
120          return [pkg, version].join('@');
121        }
122        return pkg;
123      })
124      .join(' ')
125  );
126}
127
128/** Install packages in the project. */
129async function installPackagesAsync(projectRoot: string, { packages }: { packages: string[] }) {
130  const packagesStr = chalk.bold(packages.join(', '));
131  Log.log();
132  const installingPackageStep = logNewSection(`Installing ${packagesStr}`);
133  try {
134    await installAsync(packages, { projectRoot });
135  } catch (e: any) {
136    installingPackageStep.fail(`Failed to install ${packagesStr} with error: ${e.message}`);
137    throw e;
138  }
139  installingPackageStep.succeed(`Installed ${packagesStr}`);
140}
141