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