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