1import { ExpoConfig } from '@expo/config';
2import fs from 'fs/promises';
3import path from 'path';
4
5import { updateTSConfigAsync } from './updateTSConfig';
6import * as Log from '../../../log';
7import { fileExistsAsync } from '../../../utils/dir';
8import { env } from '../../../utils/env';
9import { memoize } from '../../../utils/fn';
10import { everyMatchAsync, wrapGlobWithTimeout } from '../../../utils/glob';
11import { ProjectPrerequisite } from '../Prerequisite';
12import { ensureDependenciesAsync } from '../dependencies/ensureDependenciesAsync';
13
14const debug = require('debug')('expo:doctor:typescriptSupport') as typeof console.log;
15
16const warnDisabled = memoize(() => {
17  Log.warn('Skipping TypeScript setup: EXPO_NO_TYPESCRIPT_SETUP is enabled.');
18});
19
20/** Ensure the project has the required TypeScript support settings. */
21export class TypeScriptProjectPrerequisite extends ProjectPrerequisite<boolean> {
22  /**
23   * Ensure a project that hasn't explicitly disabled typescript support has all the required packages for running in the browser.
24   *
25   * @returns `true` if the setup finished and no longer needs to be run again.
26   */
27  async assertImplementation(): Promise<boolean> {
28    if (env.EXPO_NO_TYPESCRIPT_SETUP) {
29      warnDisabled();
30      return true;
31    }
32    debug('Ensuring TypeScript support is setup');
33
34    const tsConfigPath = path.join(this.projectRoot, 'tsconfig.json');
35
36    // Ensure the project is TypeScript before continuing.
37    const intent = await this._getSetupRequirements();
38    if (!intent) {
39      return false;
40    }
41
42    // Ensure TypeScript packages are installed
43    await this._ensureDependenciesInstalledAsync();
44
45    // Update the config
46    await updateTSConfigAsync({ tsConfigPath });
47
48    return true;
49  }
50
51  async bootstrapAsync(): Promise<void> {
52    if (env.EXPO_NO_TYPESCRIPT_SETUP) {
53      warnDisabled();
54      return;
55    }
56    // Ensure TypeScript packages are installed
57    await this._ensureDependenciesInstalledAsync({
58      skipPrompt: true,
59      isProjectMutable: true,
60    });
61
62    const tsConfigPath = path.join(this.projectRoot, 'tsconfig.json');
63
64    // Update the config
65    await updateTSConfigAsync({ tsConfigPath });
66  }
67
68  /** Exposed for testing. */
69  async _getSetupRequirements(): Promise<{
70    /** Indicates that TypeScript support is being bootstrapped. */
71    isBootstrapping: boolean;
72  } | null> {
73    const tsConfigPath = await this._hasTSConfig();
74
75    // Enable TS setup if the project has a `tsconfig.json`
76    if (tsConfigPath) {
77      const content = await fs.readFile(tsConfigPath, { encoding: 'utf8' }).then(
78        (txt) => txt.trim(),
79        // null when the file doesn't exist.
80        () => null
81      );
82      const isBlankConfig = content === '' || content === '{}';
83      return { isBootstrapping: isBlankConfig };
84    }
85    // This is a somewhat heavy check in larger projects.
86    // Test that this is reasonably paced by running expo start in `expo/apps/native-component-list`
87    const typescriptFile = await this._queryFirstTypeScriptFileAsync();
88    if (typescriptFile) {
89      return { isBootstrapping: true };
90    }
91
92    return null;
93  }
94
95  /** Exposed for testing. */
96  async _ensureDependenciesInstalledAsync({
97    exp,
98    skipPrompt,
99    isProjectMutable,
100  }: {
101    exp?: ExpoConfig;
102    skipPrompt?: boolean;
103    isProjectMutable?: boolean;
104  } = {}): Promise<boolean> {
105    try {
106      return await ensureDependenciesAsync(this.projectRoot, {
107        exp,
108        skipPrompt,
109        isProjectMutable,
110        installMessage: `It looks like you're trying to use TypeScript but don't have the required dependencies installed.`,
111        warningMessage:
112          "If you're not using TypeScript, please remove the TypeScript files from your project",
113        requiredPackages: [
114          // use typescript/package.json to skip node module cache issues when the user installs
115          // the package and attempts to resolve the module in the same process.
116          { file: 'typescript/package.json', pkg: 'typescript' },
117          { file: '@types/react/package.json', pkg: '@types/react' },
118        ],
119      });
120    } catch (error) {
121      // Reset the cached check so we can re-run the check if the user re-runs the command by pressing 'w' in the Terminal UI.
122      this.resetAssertion();
123      throw error;
124    }
125  }
126
127  /** Return the first TypeScript file in the project. */
128  async _queryFirstTypeScriptFileAsync(): Promise<null | string> {
129    const results = await wrapGlobWithTimeout(
130      () =>
131        // TODO(Bacon): Use `everyMatch` since a bug causes `anyMatch` to return inaccurate results when used multiple times.
132        everyMatchAsync('**/*.@(ts|tsx)', {
133          cwd: this.projectRoot,
134          ignore: [
135            '**/@(Carthage|Pods|node_modules)/**',
136            '**/*.d.ts',
137            '@(ios|android|web|web-build|dist)/**',
138          ],
139        }),
140      5000
141    );
142
143    if (results === false) {
144      return null;
145    }
146    return results[0] ?? null;
147  }
148
149  async _hasTSConfig(): Promise<string | null> {
150    const tsConfigPath = path.join(this.projectRoot, 'tsconfig.json');
151    if (await fileExistsAsync(tsConfigPath)) {
152      return tsConfigPath;
153    }
154    return null;
155  }
156}
157