xref: /expo/packages/@expo/cli/src/utils/npm.ts (revision 8a424beb)
18d307f52SEvan Baconimport { JSONValue } from '@expo/json-file';
28d307f52SEvan Baconimport spawnAsync from '@expo/spawn-async';
38d307f52SEvan Baconimport assert from 'assert';
48d307f52SEvan Baconimport fs from 'fs';
58d307f52SEvan Baconimport slugify from 'slugify';
68d307f52SEvan Baconimport { Stream } from 'stream';
78d307f52SEvan Baconimport tar from 'tar';
88d307f52SEvan Baconimport { promisify } from 'util';
98d307f52SEvan Bacon
108d307f52SEvan Baconimport { createEntryResolver, createFileTransform } from './createFileTransform';
118d307f52SEvan Baconimport { ensureDirectoryAsync } from './dir';
128d307f52SEvan Baconimport { CommandError } from './errors';
13*8a424bebSJames Ideimport { createCachedFetch } from '../api/rest/client';
148d307f52SEvan Bacon
15474a7a4bSEvan Baconconst debug = require('debug')('expo:utils:npm') as typeof console.log;
16474a7a4bSEvan Bacon
178c8eefe0SEvan Baconconst cachedFetch = createCachedFetch({
188c8eefe0SEvan Bacon  cacheDirectory: 'template-cache',
198d307f52SEvan Bacon  // Time to live. How long (in ms) responses remain cached before being automatically ejected. If undefined, responses are never automatically ejected from the cache.
208d307f52SEvan Bacon  // ttl: 1000,
218c8eefe0SEvan Bacon});
228d307f52SEvan Bacon
238d307f52SEvan Baconexport function sanitizeNpmPackageName(name: string): string {
248d307f52SEvan Bacon  // https://github.com/npm/validate-npm-package-name/#naming-rules
258d307f52SEvan Bacon  return (
268d307f52SEvan Bacon    applyKnownNpmPackageNameRules(name) ||
278d307f52SEvan Bacon    applyKnownNpmPackageNameRules(slugify(name)) ||
288d307f52SEvan Bacon    // If nothing is left use 'app' like we do in Xcode projects.
298d307f52SEvan Bacon    'app'
308d307f52SEvan Bacon  );
318d307f52SEvan Bacon}
328d307f52SEvan Bacon
338d307f52SEvan Baconfunction applyKnownNpmPackageNameRules(name: string): string | null {
348d307f52SEvan Bacon  // https://github.com/npm/validate-npm-package-name/#naming-rules
358d307f52SEvan Bacon
368d307f52SEvan Bacon  // package name cannot start with '.' or '_'.
378d307f52SEvan Bacon  while (/^(\.|_)/.test(name)) {
388d307f52SEvan Bacon    name = name.substring(1);
398d307f52SEvan Bacon  }
408d307f52SEvan Bacon
418d307f52SEvan Bacon  name = name.toLowerCase().replace(/[^a-zA-Z._\-/@]/g, '');
428d307f52SEvan Bacon
438d307f52SEvan Bacon  return (
448d307f52SEvan Bacon    name
458d307f52SEvan Bacon      // .replace(/![a-z0-9-._~]+/g, '')
468d307f52SEvan Bacon      // Remove special characters
478d307f52SEvan Bacon      .normalize('NFD')
488d307f52SEvan Bacon      .replace(/[\u0300-\u036f]/g, '') || null
498d307f52SEvan Bacon  );
508d307f52SEvan Bacon}
518d307f52SEvan Bacon
528d307f52SEvan Baconexport async function npmViewAsync(...props: string[]): Promise<JSONValue> {
538d307f52SEvan Bacon  const cmd = ['view', ...props, '--json'];
548d307f52SEvan Bacon  const results = (await spawnAsync('npm', cmd)).stdout?.trim();
558d307f52SEvan Bacon  const cmdString = `npm ${cmd.join(' ')}`;
56474a7a4bSEvan Bacon  debug('Run:', cmdString);
578d307f52SEvan Bacon  if (!results) {
588d307f52SEvan Bacon    return null;
598d307f52SEvan Bacon  }
608d307f52SEvan Bacon  try {
618d307f52SEvan Bacon    return JSON.parse(results);
628d307f52SEvan Bacon  } catch (error: any) {
638d307f52SEvan Bacon    throw new Error(
648d307f52SEvan Bacon      `Could not parse JSON returned from "${cmdString}".\n\n${results}\n\nError: ${error.message}`
658d307f52SEvan Bacon    );
668d307f52SEvan Bacon  }
678d307f52SEvan Bacon}
688d307f52SEvan Bacon
698d307f52SEvan Bacon/** Given a package name like `expo` or `expo@beta`, return the registry URL if it exists. */
708d307f52SEvan Baconexport async function getNpmUrlAsync(packageName: string): Promise<string> {
718d307f52SEvan Bacon  const results = await npmViewAsync(packageName, 'dist.tarball');
728d307f52SEvan Bacon
738d307f52SEvan Bacon  assert(results, `Could not get npm url for package "${packageName}"`);
748d307f52SEvan Bacon
758d307f52SEvan Bacon  // Fully qualified url returns a string.
768d307f52SEvan Bacon  // Example:
778d307f52SEvan Bacon  // �� npm view expo-template-bare-minimum@sdk-33 dist.tarball --json
788d307f52SEvan Bacon  if (typeof results === 'string') {
798d307f52SEvan Bacon    return results;
808d307f52SEvan Bacon  }
818d307f52SEvan Bacon
828d307f52SEvan Bacon  // When the tag is arbitrary, the tarball url is an array, return the last value as it's the most recent.
838d307f52SEvan Bacon  // Example:
848d307f52SEvan Bacon  // �� npm view expo-template-bare-minimum@33 dist.tarball --json
858d307f52SEvan Bacon  if (Array.isArray(results)) {
868d307f52SEvan Bacon    return results[results.length - 1] as string;
878d307f52SEvan Bacon  }
888d307f52SEvan Bacon
898d307f52SEvan Bacon  throw new CommandError(
908d307f52SEvan Bacon    'Expected results of `npm view ...` to be an array or string. Instead found: ' + results
918d307f52SEvan Bacon  );
928d307f52SEvan Bacon}
938d307f52SEvan Bacon
948d307f52SEvan Bacon// @ts-ignore
958d307f52SEvan Baconconst pipeline = promisify(Stream.pipeline);
968d307f52SEvan Bacon
978d307f52SEvan Baconexport async function downloadAndExtractNpmModuleAsync(
988d307f52SEvan Bacon  npmName: string,
998d307f52SEvan Bacon  props: ExtractProps
1008d307f52SEvan Bacon): Promise<void> {
1018d307f52SEvan Bacon  const url = await getNpmUrlAsync(npmName);
1028d307f52SEvan Bacon
103474a7a4bSEvan Bacon  debug('Fetch from URL:', url);
1048d307f52SEvan Bacon  await extractNpmTarballFromUrlAsync(url, props);
1058d307f52SEvan Bacon}
1068d307f52SEvan Bacon
1078d307f52SEvan Baconexport async function extractLocalNpmTarballAsync(
1088d307f52SEvan Bacon  tarFilePath: string,
1098d307f52SEvan Bacon  props: ExtractProps
1108d307f52SEvan Bacon): Promise<void> {
1118d307f52SEvan Bacon  const readStream = fs.createReadStream(tarFilePath);
1128d307f52SEvan Bacon  await extractNpmTarballAsync(readStream, props);
1138d307f52SEvan Bacon}
1148d307f52SEvan Bacon
1158d307f52SEvan Bacontype ExtractProps = {
1168d307f52SEvan Bacon  name: string;
1178d307f52SEvan Bacon  cwd: string;
1188d307f52SEvan Bacon  strip?: number;
1198d307f52SEvan Bacon  fileList?: string[];
1208d307f52SEvan Bacon};
1218d307f52SEvan Bacon
1228d307f52SEvan Baconasync function createUrlStreamAsync(url: string) {
1238d307f52SEvan Bacon  const response = await cachedFetch(url);
1248d307f52SEvan Bacon  if (!response.ok) {
1258d307f52SEvan Bacon    throw new Error(`Unexpected response: ${response.statusText}. From url: ${url}`);
1268d307f52SEvan Bacon  }
1278d307f52SEvan Bacon
1288d307f52SEvan Bacon  return response.body;
1298d307f52SEvan Bacon}
1308d307f52SEvan Bacon
1318d307f52SEvan Baconexport async function extractNpmTarballFromUrlAsync(
1328d307f52SEvan Bacon  url: string,
1338d307f52SEvan Bacon  props: ExtractProps
1348d307f52SEvan Bacon): Promise<void> {
1358d307f52SEvan Bacon  await extractNpmTarballAsync(await createUrlStreamAsync(url), props);
1368d307f52SEvan Bacon}
1378d307f52SEvan Bacon
1388d307f52SEvan Baconexport async function extractNpmTarballAsync(
1398d307f52SEvan Bacon  stream: NodeJS.ReadableStream,
1408d307f52SEvan Bacon  props: ExtractProps
1418d307f52SEvan Bacon): Promise<void> {
1428d307f52SEvan Bacon  const { cwd, strip, name, fileList = [] } = props;
1438d307f52SEvan Bacon
1448d307f52SEvan Bacon  await ensureDirectoryAsync(cwd);
1458d307f52SEvan Bacon
1468d307f52SEvan Bacon  await pipeline(
1478d307f52SEvan Bacon    stream,
1488d307f52SEvan Bacon    tar.extract(
1498d307f52SEvan Bacon      {
1508d307f52SEvan Bacon        cwd,
1518d307f52SEvan Bacon        transform: createFileTransform(name),
1528d307f52SEvan Bacon        onentry: createEntryResolver(name),
1538d307f52SEvan Bacon        strip: strip ?? 1,
1548d307f52SEvan Bacon      },
1558d307f52SEvan Bacon      fileList
1568d307f52SEvan Bacon    )
1578d307f52SEvan Bacon  );
1588d307f52SEvan Bacon}
159