xref: /expo/packages/create-expo/src/utils/npm.ts (revision b7d15820)
1import spawnAsync from '@expo/spawn-async';
2import fs from 'fs';
3import os from 'os';
4import path from 'path';
5import { Stream } from 'stream';
6import tar from 'tar';
7import { promisify } from 'util';
8
9import { env } from './env';
10import { createEntryResolver, createFileTransform } from '../createFileTransform';
11import { ALIASES } from '../legacyTemplates';
12import { Log } from '../log';
13
14const debug = require('debug')('expo:init:npm') as typeof console.log;
15
16type ExtractProps = {
17  name: string;
18  cwd: string;
19  strip?: number;
20  fileList?: string[];
21  disableCache?: boolean;
22};
23
24// @ts-ignore
25const pipeline = promisify(Stream.pipeline);
26
27function getTemporaryCacheFilePath(subdir: string = 'template-cache') {
28  // This is cleared when the device restarts
29  return path.join(os.tmpdir(), '.create-expo-app', subdir);
30}
31
32/** Applies the `@beta` npm tag when `EXPO_BETA` is enabled. */
33export function applyBetaTag(npmPackageName: string): string {
34  let [name, tag] = splitNpmNameAndTag(npmPackageName);
35
36  if (!tag && env.EXPO_BETA) {
37    debug('Using beta tag for', name);
38    tag = 'beta';
39  }
40
41  return joinNpmNameAndTag(name, tag);
42}
43
44/** Join an NPM package name and tag together, stripping the tag if it's `undefined`. */
45function joinNpmNameAndTag(name: string, tag: string | undefined): string {
46  return [name, tag].filter(Boolean).join('@');
47}
48
49/** Split a package name from its tag. */
50export function splitNpmNameAndTag(npmPackageName: string): [string, string | undefined] {
51  const components = npmPackageName.split('@').filter(Boolean);
52
53  if (npmPackageName.startsWith('@')) {
54    return ['@' + components[0], components[1]];
55  }
56
57  return [components[0], components[1]];
58}
59
60/**
61 * Applies known shortcuts to an NPM package name and tag.
62 * - If the name is `blank`, `blank-typescript`, `tabs`, or `bare-minimum`, apply the prefix `expo-template-`.
63 * - If a tag is a numeric value like `45`, and the name is a known template, then convert the tag to `sdk-X`.
64 *
65 * @example `blank@45` => `expo-template-blank@sdk-45`
66 */
67export function getResolvedTemplateName(npmPackageName: string) {
68  let [name, tag = 'latest'] = splitNpmNameAndTag(npmPackageName);
69
70  if (name.startsWith('@')) {
71    return joinNpmNameAndTag(name, tag);
72  }
73
74  const aliasPrefix = 'expo-template-';
75
76  if (ALIASES.includes(aliasPrefix + name)) {
77    name = aliasPrefix + name;
78  }
79
80  // Only apply the numeric conversion if the name is a known template.
81  if (ALIASES.includes(name)) {
82    if (tag?.match(/^\d+$/)) {
83      return name + '@sdk-' + tag;
84    }
85  }
86
87  return joinNpmNameAndTag(name, tag);
88}
89
90export function applyKnownNpmPackageNameRules(name: string): string | null {
91  // https://github.com/npm/validate-npm-package-name/#naming-rules
92
93  // package name cannot start with '.' or '_'.
94  while (/^(\.|_)/.test(name)) {
95    name = name.substring(1);
96  }
97
98  name = name.toLowerCase().replace(/[^a-zA-Z0-9._\-/@]/g, '');
99
100  return (
101    name
102      // .replace(/![a-z0-9-._~]+/g, '')
103      // Remove special characters
104      .normalize('NFD')
105      .replace(/[\u0300-\u036f]/g, '') || null
106  );
107}
108
109export async function extractLocalNpmTarballAsync(
110  tarFilePath: string,
111  props: ExtractProps
112): Promise<void> {
113  const readStream = fs.createReadStream(tarFilePath);
114  await extractNpmTarballAsync(readStream, props);
115}
116
117export async function extractNpmTarballAsync(
118  stream: NodeJS.ReadableStream | null,
119  props: ExtractProps
120): Promise<void> {
121  if (!stream) {
122    throw new Error('Missing stream');
123  }
124  const { cwd, strip, name, fileList = [] } = props;
125
126  await fs.promises.mkdir(cwd, { recursive: true });
127
128  await pipeline(
129    stream,
130    tar.extract(
131      {
132        cwd,
133        transform: createFileTransform(name),
134        onentry: createEntryResolver(name),
135        strip: strip ?? 1,
136      },
137      fileList
138    )
139  );
140}
141
142async function npmPackAsync(
143  packageName: string,
144  cwd: string | undefined = undefined,
145  ...props: string[]
146): Promise<NpmPackageInfo[] | null> {
147  const npm = getNpmBin();
148
149  const cmd = ['pack', packageName, ...props];
150
151  const cmdString = `${npm} ${cmd.join(' ')}`;
152  debug('Run:', cmdString, `(cwd: ${cwd ?? process.cwd()})`);
153
154  if (cwd) {
155    await fs.promises.mkdir(cwd, { recursive: true });
156  }
157
158  let results: string;
159  try {
160    results = (await spawnAsync(npm, [...cmd, '--json'], { cwd })).stdout?.trim();
161  } catch (error: any) {
162    if (error?.stderr.match(/npm ERR! code E404/)) {
163      const pkg =
164        error.stderr.match(/npm ERR! 404\s+'(.*)' is not in this registry\./)?.[1] ?? error.stderr;
165      throw new Error(`NPM package not found: ` + pkg);
166    }
167    throw error;
168  }
169
170  if (!results) {
171    return null;
172  }
173
174  try {
175    const json = JSON.parse(results);
176    if (Array.isArray(json) && json.every(isNpmPackageInfo)) {
177      return json.map(sanitizeNpmPackageFilename);
178    } else {
179      throw new Error(`Invalid response from npm: ${results}`);
180    }
181  } catch (error: any) {
182    throw new Error(
183      `Could not parse JSON returned from "${cmdString}".\n\n${results}\n\nError: ${error.message}`
184    );
185  }
186}
187
188export type NpmPackageInfo = {
189  /** "[email protected]" */
190  id: string;
191  /** "expo-template-blank" */
192  name: string;
193  /** "45.0.0" */
194  version: string;
195  /** 73765 */
196  size: number;
197  /** 90909 */
198  unpackedSize: number;
199  /** "2366988b44e4ee16eb2b0e902ee6c12a127b2c2e" */
200  shasum: string;
201  /** "sha512-oc7MjAt3sp8mi3Gf3LkKUNUkbiK7lJ7BecHMqe06n8vrStT4h2cHJKxf5dtAfgmXkBHHsQE/g7RUWrh1KbBjAw==" */
202  integrity: string;
203  /** "expo-template-blank-45.0.0.tgz" */
204  filename: string;
205  files: {
206    path: string;
207    size: number;
208    mode: number;
209  }[];
210  entryCount: number;
211  bundled: unknown[];
212};
213
214function getNpmBin() {
215  return process.platform === 'win32' ? 'npm.cmd' : 'npm';
216}
217
218async function getNpmInfoAsync(moduleId: string, cwd: string): Promise<NpmPackageInfo> {
219  const infos = await npmPackAsync(moduleId, cwd, '--dry-run');
220  if (infos?.[0]) {
221    return infos[0];
222  }
223  throw new Error(`Could not find npm package "${moduleId}"`);
224}
225
226function isNpmPackageInfo(item: any): item is NpmPackageInfo {
227  return (
228    item &&
229    typeof item === 'object' &&
230    'id' in item &&
231    'filename' in item &&
232    'version' in item &&
233    'files' in item
234  );
235}
236
237/**
238 * Adjust the tar filename in npm package info for `npm@<9.0.0`.
239 *
240 * @see https://github.com/npm/cli/issues/3405
241 */
242function sanitizeNpmPackageFilename(item: NpmPackageInfo): NpmPackageInfo {
243  if (item.filename.startsWith('@') && item.name.startsWith('@')) {
244    item.filename = item.filename.replace(/^@/, '').replace(/\//, '-');
245  }
246
247  return item;
248}
249
250async function fileExistsAsync(path: string): Promise<boolean> {
251  try {
252    const stat = await fs.promises.stat(path);
253    return stat.isFile();
254  } catch {
255    return false;
256  }
257}
258
259export async function downloadAndExtractNpmModuleAsync(
260  npmName: string,
261  props: ExtractProps
262): Promise<void> {
263  const cachePath = getTemporaryCacheFilePath();
264
265  debug(`Looking for NPM tarball (id: ${npmName}, cache: ${cachePath})`);
266
267  await fs.promises.mkdir(cachePath, { recursive: true });
268
269  const info = await getNpmInfoAsync(npmName, cachePath);
270
271  const cacheFilename = path.join(cachePath, info.filename);
272  try {
273    // TODO: This cache does not expire.
274    const fileExists = await fileExistsAsync(cacheFilename);
275
276    const disableCache = env.EXPO_NO_CACHE || props.disableCache;
277    if (disableCache || !fileExists) {
278      debug(`Downloading tarball for ${npmName} to ${cachePath}...`);
279      await npmPackAsync(npmName, cachePath);
280    }
281  } catch (error: any) {
282    Log.error('Error downloading template package: ' + npmName);
283    throw error;
284  }
285
286  try {
287    await extractLocalNpmTarballAsync(cacheFilename, {
288      cwd: props.cwd,
289      name: props.name,
290    });
291  } catch (error: any) {
292    Log.error('Error extracting template package: ' + npmName);
293    throw error;
294  }
295}
296