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