18d307f52SEvan Baconimport { ExpoConfig } from '@expo/config-types'; 28d307f52SEvan Baconimport chalk from 'chalk'; 38d307f52SEvan Baconimport fs from 'fs'; 48d307f52SEvan Baconimport { Ora } from 'ora'; 58d307f52SEvan Baconimport path from 'path'; 68d307f52SEvan Baconimport semver from 'semver'; 78d307f52SEvan Bacon 88c8eefe0SEvan Baconimport { fetchAsync } from '../api/rest/client'; 98d307f52SEvan Baconimport * as Log from '../log'; 108d307f52SEvan Baconimport { AbortCommandError, CommandError } from '../utils/errors'; 118d307f52SEvan Baconimport { 128d307f52SEvan Bacon downloadAndExtractNpmModuleAsync, 138d307f52SEvan Bacon extractLocalNpmTarballAsync, 148d307f52SEvan Bacon extractNpmTarballFromUrlAsync, 158d307f52SEvan Bacon} from '../utils/npm'; 168d307f52SEvan Baconimport { isUrlOk } from '../utils/url'; 178d307f52SEvan Bacon 18474a7a4bSEvan Baconconst debug = require('debug')('expo:prebuild:resolveTemplate') as typeof console.log; 19474a7a4bSEvan Bacon 208d307f52SEvan Bacontype RepoInfo = { 218d307f52SEvan Bacon username: string; 228d307f52SEvan Bacon name: string; 238d307f52SEvan Bacon branch: string; 248d307f52SEvan Bacon filePath: string; 258d307f52SEvan Bacon}; 268d307f52SEvan Bacon 278d307f52SEvan Baconexport async function cloneTemplateAsync({ 288d307f52SEvan Bacon templateDirectory, 298d307f52SEvan Bacon template, 308d307f52SEvan Bacon exp, 318d307f52SEvan Bacon ora, 328d307f52SEvan Bacon}: { 338d307f52SEvan Bacon templateDirectory: string; 348d307f52SEvan Bacon template?: string; 358d307f52SEvan Bacon exp: Pick<ExpoConfig, 'name' | 'sdkVersion'>; 368d307f52SEvan Bacon ora: Ora; 378d307f52SEvan Bacon}) { 388d307f52SEvan Bacon if (template) { 398d307f52SEvan Bacon await resolveTemplateArgAsync(templateDirectory, ora, exp.name, template); 408d307f52SEvan Bacon } else { 418d307f52SEvan Bacon const templatePackageName = await getTemplateNpmPackageName(exp.sdkVersion); 428d307f52SEvan Bacon await downloadAndExtractNpmModuleAsync(templatePackageName, { 438d307f52SEvan Bacon cwd: templateDirectory, 448d307f52SEvan Bacon name: exp.name, 458d307f52SEvan Bacon }); 468d307f52SEvan Bacon } 478d307f52SEvan Bacon} 488d307f52SEvan Bacon 498d307f52SEvan Bacon/** Given an `sdkVersion` like `44.0.0` return a fully qualified NPM package name like: `expo-template-bare-minimum@sdk-44` */ 508d307f52SEvan Baconfunction getTemplateNpmPackageName(sdkVersion?: string): string { 518d307f52SEvan Bacon // When undefined or UNVERSIONED, we use the latest version. 528d307f52SEvan Bacon if (!sdkVersion || sdkVersion === 'UNVERSIONED') { 538d307f52SEvan Bacon Log.log('Using an unspecified Expo SDK version. The latest template will be used.'); 548d307f52SEvan Bacon return `expo-template-bare-minimum@latest`; 558d307f52SEvan Bacon } 568d307f52SEvan Bacon return `expo-template-bare-minimum@sdk-${semver.major(sdkVersion)}`; 578d307f52SEvan Bacon} 588d307f52SEvan Bacon 598d307f52SEvan Baconasync function getRepoInfo(url: any, examplePath?: string): Promise<RepoInfo | undefined> { 608d307f52SEvan Bacon const [, username, name, t, _branch, ...file] = url.pathname.split('/'); 618d307f52SEvan Bacon const filePath = examplePath ? examplePath.replace(/^\//, '') : file.join('/'); 628d307f52SEvan Bacon 638d307f52SEvan Bacon // Support repos whose entire purpose is to be an example, e.g. 648d307f52SEvan Bacon // https://github.com/:username/:my-cool-example-repo-name. 658d307f52SEvan Bacon if (t === undefined) { 668c8eefe0SEvan Bacon const infoResponse = await fetchAsync(`https://api.github.com/repos/${username}/${name}`); 678d307f52SEvan Bacon if (infoResponse.status !== 200) { 688d307f52SEvan Bacon return; 698d307f52SEvan Bacon } 708d307f52SEvan Bacon const info = await infoResponse.json(); 718d307f52SEvan Bacon return { username, name, branch: info['default_branch'], filePath }; 728d307f52SEvan Bacon } 738d307f52SEvan Bacon 748d307f52SEvan Bacon // If examplePath is available, the branch name takes the entire path 758d307f52SEvan Bacon const branch = examplePath 768d307f52SEvan Bacon ? `${_branch}/${file.join('/')}`.replace(new RegExp(`/${filePath}|/$`), '') 778d307f52SEvan Bacon : _branch; 788d307f52SEvan Bacon 798d307f52SEvan Bacon if (username && name && branch && t === 'tree') { 808d307f52SEvan Bacon return { username, name, branch, filePath }; 818d307f52SEvan Bacon } 828d307f52SEvan Bacon return undefined; 838d307f52SEvan Bacon} 848d307f52SEvan Bacon 858d307f52SEvan Baconfunction hasRepo({ username, name, branch, filePath }: RepoInfo) { 868d307f52SEvan Bacon const contentsUrl = `https://api.github.com/repos/${username}/${name}/contents`; 878d307f52SEvan Bacon const packagePath = `${filePath ? `/${filePath}` : ''}/package.json`; 888d307f52SEvan Bacon 898d307f52SEvan Bacon return isUrlOk(contentsUrl + packagePath + `?ref=${branch}`); 908d307f52SEvan Bacon} 918d307f52SEvan Bacon 928d307f52SEvan Baconasync function downloadAndExtractRepoAsync( 938d307f52SEvan Bacon root: string, 948d307f52SEvan Bacon { username, name, branch, filePath }: RepoInfo 958d307f52SEvan Bacon): Promise<void> { 968d307f52SEvan Bacon const projectName = path.basename(root); 978d307f52SEvan Bacon 988d307f52SEvan Bacon const strip = filePath ? filePath.split('/').length + 1 : 1; 998d307f52SEvan Bacon 1008d307f52SEvan Bacon const url = `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`; 101474a7a4bSEvan Bacon debug('Downloading tarball from:', url); 1028d307f52SEvan Bacon await extractNpmTarballFromUrlAsync(url, { 1038d307f52SEvan Bacon cwd: root, 1048d307f52SEvan Bacon name: projectName, 1058d307f52SEvan Bacon strip, 1068d307f52SEvan Bacon fileList: [`${name}-${branch}${filePath ? `/${filePath}` : ''}`], 1078d307f52SEvan Bacon }); 1088d307f52SEvan Bacon} 1098d307f52SEvan Bacon 1108d307f52SEvan Baconexport async function resolveTemplateArgAsync( 1118d307f52SEvan Bacon templateDirectory: string, 1128d307f52SEvan Bacon oraInstance: Ora, 1138d307f52SEvan Bacon appName: string, 1148d307f52SEvan Bacon template: string, 1158d307f52SEvan Bacon templatePath?: string 1168d307f52SEvan Bacon) { 1178d307f52SEvan Bacon let repoInfo: RepoInfo | undefined; 1188d307f52SEvan Bacon 1198d307f52SEvan Bacon if (template) { 1208d307f52SEvan Bacon // @ts-ignore 1218d307f52SEvan Bacon let repoUrl: URL | undefined; 1228d307f52SEvan Bacon 1238d307f52SEvan Bacon try { 1248d307f52SEvan Bacon // @ts-ignore 1258d307f52SEvan Bacon repoUrl = new URL(template); 1268d307f52SEvan Bacon } catch (error: any) { 1278d307f52SEvan Bacon if (error.code !== 'ERR_INVALID_URL') { 1288d307f52SEvan Bacon oraInstance.fail(error); 1298d307f52SEvan Bacon throw error; 1308d307f52SEvan Bacon } 1318d307f52SEvan Bacon } 1328d307f52SEvan Bacon 133*670287b3SCedric van Putten // On Windows, we can actually create a URL from a local path 134*670287b3SCedric van Putten // Double-check if the created URL is not a path to avoid mixing up URLs and paths 135*670287b3SCedric van Putten if (process.platform === 'win32' && repoUrl && path.isAbsolute(repoUrl.toString())) { 136*670287b3SCedric van Putten repoUrl = undefined; 137*670287b3SCedric van Putten } 138*670287b3SCedric van Putten 1398d307f52SEvan Bacon if (!repoUrl) { 1408d307f52SEvan Bacon const templatePath = path.resolve(template); 1418d307f52SEvan Bacon if (!fs.existsSync(templatePath)) { 1428d307f52SEvan Bacon throw new CommandError(`template file does not exist: ${templatePath}`); 1438d307f52SEvan Bacon } 1448d307f52SEvan Bacon 1458d307f52SEvan Bacon await extractLocalNpmTarballAsync(templatePath, { cwd: templateDirectory, name: appName }); 1468d307f52SEvan Bacon return templateDirectory; 1478d307f52SEvan Bacon } 1488d307f52SEvan Bacon 1498d307f52SEvan Bacon if (repoUrl.origin !== 'https://github.com') { 1508d307f52SEvan Bacon oraInstance.fail( 1518d307f52SEvan Bacon `Invalid URL: ${chalk.red( 1528d307f52SEvan Bacon `"${template}"` 1538d307f52SEvan Bacon )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` 1548d307f52SEvan Bacon ); 1558d307f52SEvan Bacon throw new AbortCommandError(); 1568d307f52SEvan Bacon } 1578d307f52SEvan Bacon 1588d307f52SEvan Bacon repoInfo = await getRepoInfo(repoUrl, templatePath); 1598d307f52SEvan Bacon 1608d307f52SEvan Bacon if (!repoInfo) { 1618d307f52SEvan Bacon oraInstance.fail( 1628d307f52SEvan Bacon `Found invalid GitHub URL: ${chalk.red(`"${template}"`)}. Please fix the URL and try again.` 1638d307f52SEvan Bacon ); 1648d307f52SEvan Bacon throw new AbortCommandError(); 1658d307f52SEvan Bacon } 1668d307f52SEvan Bacon 1678d307f52SEvan Bacon const found = await hasRepo(repoInfo); 1688d307f52SEvan Bacon 1698d307f52SEvan Bacon if (!found) { 1708d307f52SEvan Bacon oraInstance.fail( 1718d307f52SEvan Bacon `Could not locate the repository for ${chalk.red( 1728d307f52SEvan Bacon `"${template}"` 1738d307f52SEvan Bacon )}. Please check that the repository exists and try again.` 1748d307f52SEvan Bacon ); 1758d307f52SEvan Bacon throw new AbortCommandError(); 1768d307f52SEvan Bacon } 1778d307f52SEvan Bacon } 1788d307f52SEvan Bacon 1798d307f52SEvan Bacon if (repoInfo) { 1808d307f52SEvan Bacon oraInstance.text = chalk.bold( 1818d307f52SEvan Bacon `Downloading files from repo ${chalk.cyan(template)}. This might take a moment.` 1828d307f52SEvan Bacon ); 1838d307f52SEvan Bacon 1848d307f52SEvan Bacon await downloadAndExtractRepoAsync(templateDirectory, repoInfo); 1858d307f52SEvan Bacon } 1868d307f52SEvan Bacon 1878d307f52SEvan Bacon return true; 1888d307f52SEvan Bacon} 189