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