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