1import { ExpoConfig } from '@expo/config-types'; 2import chalk from 'chalk'; 3import fs from 'fs'; 4import { Ora } from 'ora'; 5import path from 'path'; 6import semver from 'semver'; 7 8import { fetchAsync } from '../api/rest/client'; 9import * as Log from '../log'; 10import { AbortCommandError, CommandError } from '../utils/errors'; 11import { 12 downloadAndExtractNpmModuleAsync, 13 extractLocalNpmTarballAsync, 14 extractNpmTarballFromUrlAsync, 15} from '../utils/npm'; 16import { isUrlOk } from '../utils/url'; 17 18const debug = require('debug')('expo:prebuild:resolveTemplate') as typeof console.log; 19 20type RepoInfo = { 21 username: string; 22 name: string; 23 branch: string; 24 filePath: string; 25}; 26 27export async function cloneTemplateAsync({ 28 templateDirectory, 29 template, 30 exp, 31 ora, 32}: { 33 templateDirectory: string; 34 template?: string; 35 exp: Pick<ExpoConfig, 'name' | 'sdkVersion'>; 36 ora: Ora; 37}) { 38 if (template) { 39 await resolveTemplateArgAsync(templateDirectory, ora, exp.name, template); 40 } else { 41 const templatePackageName = await getTemplateNpmPackageName(exp.sdkVersion); 42 await downloadAndExtractNpmModuleAsync(templatePackageName, { 43 cwd: templateDirectory, 44 name: exp.name, 45 }); 46 } 47} 48 49/** Given an `sdkVersion` like `44.0.0` return a fully qualified NPM package name like: `expo-template-bare-minimum@sdk-44` */ 50function getTemplateNpmPackageName(sdkVersion?: string): string { 51 // When undefined or UNVERSIONED, we use the latest version. 52 if (!sdkVersion || sdkVersion === 'UNVERSIONED') { 53 Log.log('Using an unspecified Expo SDK version. The latest template will be used.'); 54 return `expo-template-bare-minimum@latest`; 55 } 56 return `expo-template-bare-minimum@sdk-${semver.major(sdkVersion)}`; 57} 58 59async function getRepoInfo(url: any, examplePath?: string): Promise<RepoInfo | undefined> { 60 const [, username, name, t, _branch, ...file] = url.pathname.split('/'); 61 const filePath = examplePath ? examplePath.replace(/^\//, '') : file.join('/'); 62 63 // Support repos whose entire purpose is to be an example, e.g. 64 // https://github.com/:username/:my-cool-example-repo-name. 65 if (t === undefined) { 66 const infoResponse = await fetchAsync(`https://api.github.com/repos/${username}/${name}`); 67 if (infoResponse.status !== 200) { 68 return; 69 } 70 const info = await infoResponse.json(); 71 return { username, name, branch: info['default_branch'], filePath }; 72 } 73 74 // If examplePath is available, the branch name takes the entire path 75 const branch = examplePath 76 ? `${_branch}/${file.join('/')}`.replace(new RegExp(`/${filePath}|/$`), '') 77 : _branch; 78 79 if (username && name && branch && t === 'tree') { 80 return { username, name, branch, filePath }; 81 } 82 return undefined; 83} 84 85function hasRepo({ username, name, branch, filePath }: RepoInfo) { 86 const contentsUrl = `https://api.github.com/repos/${username}/${name}/contents`; 87 const packagePath = `${filePath ? `/${filePath}` : ''}/package.json`; 88 89 return isUrlOk(contentsUrl + packagePath + `?ref=${branch}`); 90} 91 92async function downloadAndExtractRepoAsync( 93 root: string, 94 { username, name, branch, filePath }: RepoInfo 95): Promise<void> { 96 const projectName = path.basename(root); 97 98 const strip = filePath ? filePath.split('/').length + 1 : 1; 99 100 const url = `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`; 101 debug('Downloading tarball from:', url); 102 await extractNpmTarballFromUrlAsync(url, { 103 cwd: root, 104 name: projectName, 105 strip, 106 fileList: [`${name}-${branch}${filePath ? `/${filePath}` : ''}`], 107 }); 108} 109 110export async function resolveTemplateArgAsync( 111 templateDirectory: string, 112 oraInstance: Ora, 113 appName: string, 114 template: string, 115 templatePath?: string 116) { 117 let repoInfo: RepoInfo | undefined; 118 119 if (template) { 120 // @ts-ignore 121 let repoUrl: URL | undefined; 122 123 try { 124 // @ts-ignore 125 repoUrl = new URL(template); 126 } catch (error: any) { 127 if (error.code !== 'ERR_INVALID_URL') { 128 oraInstance.fail(error); 129 throw error; 130 } 131 } 132 133 // On Windows, we can actually create a URL from a local path 134 // Double-check if the created URL is not a path to avoid mixing up URLs and paths 135 if (process.platform === 'win32' && repoUrl && path.isAbsolute(repoUrl.toString())) { 136 repoUrl = undefined; 137 } 138 139 if (!repoUrl) { 140 const templatePath = path.resolve(template); 141 if (!fs.existsSync(templatePath)) { 142 throw new CommandError(`template file does not exist: ${templatePath}`); 143 } 144 145 await extractLocalNpmTarballAsync(templatePath, { cwd: templateDirectory, name: appName }); 146 return templateDirectory; 147 } 148 149 if (repoUrl.origin !== 'https://github.com') { 150 oraInstance.fail( 151 `Invalid URL: ${chalk.red( 152 `"${template}"` 153 )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` 154 ); 155 throw new AbortCommandError(); 156 } 157 158 repoInfo = await getRepoInfo(repoUrl, templatePath); 159 160 if (!repoInfo) { 161 oraInstance.fail( 162 `Found invalid GitHub URL: ${chalk.red(`"${template}"`)}. Please fix the URL and try again.` 163 ); 164 throw new AbortCommandError(); 165 } 166 167 const found = await hasRepo(repoInfo); 168 169 if (!found) { 170 oraInstance.fail( 171 `Could not locate the repository for ${chalk.red( 172 `"${template}"` 173 )}. Please check that the repository exists and try again.` 174 ); 175 throw new AbortCommandError(); 176 } 177 } 178 179 if (repoInfo) { 180 oraInstance.text = chalk.bold( 181 `Downloading files from repo ${chalk.cyan(template)}. This might take a moment.` 182 ); 183 184 await downloadAndExtractRepoAsync(templateDirectory, repoInfo); 185 } 186 187 return true; 188} 189