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