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