diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index 1d461bcf717f9..715bfd19de530 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -11,6 +11,7 @@ ### 💡 Others - Remove classic updates SDK version. ([#26061](https://github.com/expo/expo/pull/26061) by [@wschurman](https://github.com/wschurman)) +- Added `templateChecksum` for prebuild to check the current template version. ([#26414](https://github.com/expo/expo/pull/26414) by [@kudo](https://github.com/kudo)) ## 0.16.8 - 2024-01-15 diff --git a/packages/@expo/cli/src/export/exportStaticAsync.ts b/packages/@expo/cli/src/export/exportStaticAsync.ts index 432c6e78e5834..01823e7599c23 100644 --- a/packages/@expo/cli/src/export/exportStaticAsync.ts +++ b/packages/@expo/cli/src/export/exportStaticAsync.ts @@ -305,8 +305,8 @@ export function getHtmlFiles({ baseUrl === '' ? 'index' : baseUrl.endsWith('/') - ? baseUrl + 'index' - : baseUrl.slice(0, -1); + ? baseUrl + 'index' + : baseUrl.slice(0, -1); } // This should never happen, the type of `string | object` originally comes from React Navigation. diff --git a/packages/@expo/cli/src/prebuild/configureProjectAsync.ts b/packages/@expo/cli/src/prebuild/configureProjectAsync.ts index e6e03877d1ed1..a1036b2249b74 100644 --- a/packages/@expo/cli/src/prebuild/configureProjectAsync.ts +++ b/packages/@expo/cli/src/prebuild/configureProjectAsync.ts @@ -15,9 +15,11 @@ export async function configureProjectAsync( { platforms, exp, + templateChecksum, }: { platforms: ModPlatform[]; exp?: ExpoConfig; + templateChecksum?: string; } ): Promise { let bundleIdentifier: string | undefined; @@ -37,6 +39,12 @@ export async function configureProjectAsync( bundleIdentifier, }); + if (templateChecksum) { + // Prepare template checksum for the patch mods + config._internal = config._internal ?? {}; + config._internal.templateChecksum = templateChecksum; + } + // compile all plugins and mods config = await compileModsAsync(config, { projectRoot, diff --git a/packages/@expo/cli/src/prebuild/prebuildAsync.ts b/packages/@expo/cli/src/prebuild/prebuildAsync.ts index 5a1f0daa7e3d0..92a8c3c08331f 100644 --- a/packages/@expo/cli/src/prebuild/prebuildAsync.ts +++ b/packages/@expo/cli/src/prebuild/prebuildAsync.ts @@ -87,7 +87,7 @@ export async function prebuildAsync( const { exp, pkg } = await ensureConfigAsync(projectRoot, { platforms: options.platforms }); // Create native projects from template. - const { hasNewProjectFiles, needsPodInstall, changedDependencies } = + const { hasNewProjectFiles, needsPodInstall, templateChecksum, changedDependencies } = await updateFromTemplateAsync(projectRoot, { exp, pkg, @@ -142,6 +142,7 @@ export async function prebuildAsync( await profile(configureProjectAsync)(projectRoot, { platforms: options.platforms, exp, + templateChecksum, }); configSyncingStep.succeed('Finished prebuild'); } catch (error) { diff --git a/packages/@expo/cli/src/prebuild/resolveTemplate.ts b/packages/@expo/cli/src/prebuild/resolveTemplate.ts index b0c15c0324b57..947e7c2f24c47 100644 --- a/packages/@expo/cli/src/prebuild/resolveTemplate.ts +++ b/packages/@expo/cli/src/prebuild/resolveTemplate.ts @@ -1,4 +1,5 @@ import { ExpoConfig } from '@expo/config'; +import assert from 'assert'; import chalk from 'chalk'; import fs from 'fs'; import { Ora } from 'ora'; @@ -34,12 +35,12 @@ export async function cloneTemplateAsync({ template?: string; exp: Pick; ora: Ora; -}) { +}): Promise { if (template) { - await resolveTemplateArgAsync(templateDirectory, ora, exp.name, template); + return await resolveTemplateArgAsync(templateDirectory, ora, exp.name, template); } else { const templatePackageName = await getTemplateNpmPackageName(exp.sdkVersion); - await downloadAndExtractNpmModuleAsync(templatePackageName, { + return await downloadAndExtractNpmModuleAsync(templatePackageName, { cwd: templateDirectory, name: exp.name, }); @@ -92,14 +93,14 @@ function hasRepo({ username, name, branch, filePath }: RepoInfo) { async function downloadAndExtractRepoAsync( root: string, { username, name, branch, filePath }: RepoInfo -): Promise { +): Promise { const projectName = path.basename(root); const strip = filePath ? filePath.split('/').length + 1 : 1; const url = `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`; debug('Downloading tarball from:', url); - await extractNpmTarballFromUrlAsync(url, { + return await extractNpmTarballFromUrlAsync(url, { cwd: root, name: projectName, strip, @@ -113,76 +114,70 @@ export async function resolveTemplateArgAsync( appName: string, template: string, templatePath?: string -) { - let repoInfo: RepoInfo | undefined; +): Promise { + assert(template, 'template is required'); - if (template) { - // @ts-ignore - let repoUrl: URL | undefined; - - try { - // @ts-ignore - repoUrl = new URL(template); - } catch (error: any) { - if (error.code !== 'ERR_INVALID_URL') { - oraInstance.fail(error); - throw error; - } - } + let repoUrl: URL | undefined; - // On Windows, we can actually create a URL from a local path - // Double-check if the created URL is not a path to avoid mixing up URLs and paths - if (process.platform === 'win32' && repoUrl && path.isAbsolute(repoUrl.toString())) { - repoUrl = undefined; + try { + repoUrl = new URL(template); + } catch (error: any) { + if (error.code !== 'ERR_INVALID_URL') { + oraInstance.fail(error); + throw error; } + } - if (!repoUrl) { - const templatePath = path.resolve(template); - if (!fs.existsSync(templatePath)) { - throw new CommandError(`template file does not exist: ${templatePath}`); - } - - await extractLocalNpmTarballAsync(templatePath, { cwd: templateDirectory, name: appName }); - return templateDirectory; - } + // On Windows, we can actually create a URL from a local path + // Double-check if the created URL is not a path to avoid mixing up URLs and paths + if (process.platform === 'win32' && repoUrl && path.isAbsolute(repoUrl.toString())) { + repoUrl = undefined; + } - if (repoUrl.origin !== 'https://github.com') { - oraInstance.fail( - `Invalid URL: ${chalk.red( - `"${template}"` - )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` - ); - throw new AbortCommandError(); + if (!repoUrl) { + const templatePath = path.resolve(template); + if (!fs.existsSync(templatePath)) { + throw new CommandError(`template file does not exist: ${templatePath}`); } - repoInfo = await getRepoInfo(repoUrl, templatePath); + return await extractLocalNpmTarballAsync(templatePath, { + cwd: templateDirectory, + name: appName, + }); + } - if (!repoInfo) { - oraInstance.fail( - `Found invalid GitHub URL: ${chalk.red(`"${template}"`)}. Please fix the URL and try again.` - ); - throw new AbortCommandError(); - } + if (repoUrl.origin !== 'https://github.com') { + oraInstance.fail( + `Invalid URL: ${chalk.red( + `"${template}"` + )}. Only GitHub repositories are supported. Please use a GitHub URL and try again.` + ); + throw new AbortCommandError(); + } - const found = await hasRepo(repoInfo); + const repoInfo = await getRepoInfo(repoUrl, templatePath); - if (!found) { - oraInstance.fail( - `Could not locate the repository for ${chalk.red( - `"${template}"` - )}. Please check that the repository exists and try again.` - ); - throw new AbortCommandError(); - } + if (!repoInfo) { + oraInstance.fail( + `Found invalid GitHub URL: ${chalk.red(`"${template}"`)}. Please fix the URL and try again.` + ); + throw new AbortCommandError(); } - if (repoInfo) { - oraInstance.text = chalk.bold( - `Downloading files from repo ${chalk.cyan(template)}. This might take a moment.` - ); + const found = await hasRepo(repoInfo); - await downloadAndExtractRepoAsync(templateDirectory, repoInfo); + if (!found) { + oraInstance.fail( + `Could not locate the repository for ${chalk.red( + `"${template}"` + )}. Please check that the repository exists and try again.` + ); + throw new AbortCommandError(); } - return true; + oraInstance.text = chalk.bold( + `Downloading files from repo ${chalk.cyan(template)}. This might take a moment.` + ); + + return await downloadAndExtractRepoAsync(templateDirectory, repoInfo); } diff --git a/packages/@expo/cli/src/prebuild/updateFromTemplate.ts b/packages/@expo/cli/src/prebuild/updateFromTemplate.ts index 9a306a4132097..f8691a9e8345c 100644 --- a/packages/@expo/cli/src/prebuild/updateFromTemplate.ts +++ b/packages/@expo/cli/src/prebuild/updateFromTemplate.ts @@ -45,6 +45,8 @@ export async function updateFromTemplateAsync( hasNewProjectFiles: boolean; /** Indicates that the project needs to run `pod install` */ needsPodInstall: boolean; + /** The template checksum used to create the native project. */ + templateChecksum: string; } & DependenciesModificationResults > { if (!templateDirectory) { @@ -52,7 +54,7 @@ export async function updateFromTemplateAsync( templateDirectory = temporary.directory(); } - const copiedPaths = await profile(cloneTemplateAndCopyToProjectAsync)({ + const { copiedPaths, templateChecksum } = await profile(cloneTemplateAndCopyToProjectAsync)({ projectRoot, template, templateDirectory, @@ -70,6 +72,7 @@ export async function updateFromTemplateAsync( hasNewProjectFiles: !!copiedPaths.length, // If the iOS folder changes or new packages are added, we should rerun pod install. needsPodInstall: copiedPaths.includes('ios') || !!depsResults.changedDependencies.length, + templateChecksum, ...depsResults, }; } @@ -79,7 +82,7 @@ export async function updateFromTemplateAsync( * * @return `true` if any project files were created. */ -async function cloneTemplateAndCopyToProjectAsync({ +export async function cloneTemplateAndCopyToProjectAsync({ projectRoot, templateDirectory, template, @@ -91,7 +94,7 @@ async function cloneTemplateAndCopyToProjectAsync({ template?: string; exp: Pick; platforms: ModPlatform[]; -}): Promise { +}): Promise<{ copiedPaths: string[]; templateChecksum: string }> { const platformDirectories = unknownPlatforms .map((platform) => `./${platform}`) .reverse() @@ -101,7 +104,7 @@ async function cloneTemplateAndCopyToProjectAsync({ const ora = logNewSection(`Creating native ${pluralized} (${platformDirectories})`); try { - await cloneTemplateAsync({ templateDirectory, template, exp, ora }); + const templateChecksum = await cloneTemplateAsync({ templateDirectory, template, exp, ora }); const platforms = validateTemplatePlatforms({ templateDirectory, @@ -115,7 +118,10 @@ async function cloneTemplateAndCopyToProjectAsync({ ora.succeed(createCopyFilesSuccessMessage(platforms, results)); - return results.copiedPaths; + return { + copiedPaths: results.copiedPaths, + templateChecksum, + }; } catch (e: any) { if (!(e instanceof AbortCommandError)) { Log.error(e.message); diff --git a/packages/@expo/cli/src/start/server/metro/__tests__/createExpoMetroResolver.test.ts b/packages/@expo/cli/src/start/server/metro/__tests__/createExpoMetroResolver.test.ts index 01b8f7a8d53a3..c6bd9e96bd25c 100644 --- a/packages/@expo/cli/src/start/server/metro/__tests__/createExpoMetroResolver.test.ts +++ b/packages/@expo/cli/src/start/server/metro/__tests__/createExpoMetroResolver.test.ts @@ -37,8 +37,8 @@ const createContext = ({ mainFields: preferNativePlatform ? ['react-native', 'browser', 'main'] : isServer - ? ['main', 'module'] - : ['browser', 'module', 'main'], + ? ['main', 'module'] + : ['browser', 'module', 'main'], nodeModulesPaths: ['node_modules', ...nodeModulesPaths], originModulePath: origin, preferNativePlatform, @@ -48,8 +48,8 @@ const createContext = ({ unstable_conditionNames: isServer ? ['node', 'require'] : platform === 'web' - ? ['require', 'import', 'browser'] - : ['require', 'import', 'react-native'], + ? ['require', 'import', 'browser'] + : ['require', 'import', 'react-native'], }; }; @@ -124,8 +124,8 @@ function resolveTo( return res.type === 'sourceFile' ? res.filePath : res.type === 'assetFiles' - ? res.filePaths[0] - : null; + ? res.filePaths[0] + : null; } describe(createFastResolver, () => { diff --git a/packages/@expo/cli/src/start/server/type-generation/__typetests__/fixtures/basic.ts b/packages/@expo/cli/src/start/server/type-generation/__typetests__/fixtures/basic.ts index 086a0086cd69f..22244dd072eec 100644 --- a/packages/@expo/cli/src/start/server/type-generation/__typetests__/fixtures/basic.ts +++ b/packages/@expo/cli/src/start/server/type-generation/__typetests__/fixtures/basic.ts @@ -43,14 +43,14 @@ type UnknownOutputParams = Record; type SingleRoutePart = S extends `${string}/${string}` ? never : S extends `${string}${SearchOrHash}` - ? never - : S extends '' - ? never - : S extends `(${string})` - ? never - : S extends `[${string}]` - ? never - : S; + ? never + : S extends '' + ? never + : S extends `(${string})` + ? never + : S extends `[${string}]` + ? never + : S; /** * Return only the CatchAll router part. If the string has search parameters or a hash return never @@ -58,12 +58,12 @@ type SingleRoutePart = S extends `${string}/${string}` type CatchAllRoutePart = S extends `${string}${SearchOrHash}` ? never : S extends '' - ? never - : S extends `${string}(${string})${string}` - ? never - : S extends `${string}[${string}]${string}` - ? never - : S; + ? never + : S extends `${string}(${string})${string}` + ? never + : S extends `${string}[${string}]${string}` + ? never + : S; // type OptionalCatchAllRoutePart = S extends `${string}${SearchOrHash}` ? never : S @@ -95,8 +95,8 @@ type RouteSegments = Path extends `${infer PartA}/${infer PartB}` ? [...RouteSegments] : [PartA, ...RouteSegments] : Path extends '' - ? [] - : [Path]; + ? [] + : [Path]; /** * Returns a Record of the routes parameters as strings and CatchAll parameters @@ -125,8 +125,8 @@ type OutputRouteParams = { export type SearchParams = T extends DynamicRouteTemplate ? OutputRouteParams : T extends StaticRoutes - ? never - : UnknownOutputParams; + ? never + : UnknownOutputParams; /** * Route is mostly used as part of Href to ensure that a valid route is provided @@ -153,8 +153,8 @@ export type Route = T extends string ? T : never : T extends DynamicRoutes - ? T - : never) + ? T + : never) : never; /********* @@ -169,8 +169,8 @@ export type HrefObject< > = P extends DynamicRouteTemplate ? { pathname: P; params: InputRouteParams

} : P extends Route

- ? { pathname: Route

| DynamicRouteTemplate; params?: never | InputRouteParams } - : never; + ? { pathname: Route

| DynamicRouteTemplate; params?: never | InputRouteParams } + : never; /*********************** * Expo Router Exports * diff --git a/packages/@expo/cli/src/start/server/type-generation/routes.ts b/packages/@expo/cli/src/start/server/type-generation/routes.ts index ef0fecaa7f13c..7cec5adedb143 100644 --- a/packages/@expo/cli/src/start/server/type-generation/routes.ts +++ b/packages/@expo/cli/src/start/server/type-generation/routes.ts @@ -345,14 +345,14 @@ declare module "expo-router" { type SingleRoutePart = S extends \`\${string}/\${string}\` ? never : S extends \`\${string}\${SearchOrHash}\` - ? never - : S extends '' - ? never - : S extends \`(\${string})\` - ? never - : S extends \`[\${string}]\` - ? never - : S; + ? never + : S extends '' + ? never + : S extends \`(\${string})\` + ? never + : S extends \`[\${string}]\` + ? never + : S; /** * Return only the CatchAll router part. If the string has search parameters or a hash return never @@ -360,12 +360,12 @@ declare module "expo-router" { type CatchAllRoutePart = S extends \`\${string}\${SearchOrHash}\` ? never : S extends '' - ? never - : S extends \`\${string}(\${string})\${string}\` - ? never - : S extends \`\${string}[\${string}]\${string}\` - ? never - : S; + ? never + : S extends \`\${string}(\${string})\${string}\` + ? never + : S extends \`\${string}[\${string}]\${string}\` + ? never + : S; // type OptionalCatchAllRoutePart = S extends \`\${string}\${SearchOrHash}\` ? never : S @@ -397,8 +397,8 @@ declare module "expo-router" { ? [...RouteSegments] : [PartA, ...RouteSegments] : Path extends '' - ? [] - : [Path]; + ? [] + : [Path]; /** * Returns a Record of the routes parameters as strings and CatchAll parameters @@ -427,8 +427,8 @@ declare module "expo-router" { export type SearchParams = T extends DynamicRouteTemplate ? OutputRouteParams : T extends StaticRoutes - ? never - : UnknownOutputParams; + ? never + : UnknownOutputParams; /** * Route is mostly used as part of Href to ensure that a valid route is provided @@ -455,8 +455,8 @@ declare module "expo-router" { ? T : never : T extends DynamicRoutes - ? T - : never) + ? T + : never) : never; /********* @@ -471,8 +471,8 @@ declare module "expo-router" { > = P extends DynamicRouteTemplate ? { pathname: P; params: InputRouteParams

} : P extends Route

- ? { pathname: Route

| DynamicRouteTemplate; params?: never | InputRouteParams } - : never; + ? { pathname: Route

| DynamicRouteTemplate; params?: never | InputRouteParams } + : never; /*********************** * Expo Router Exports * diff --git a/packages/@expo/cli/src/utils/__tests__/npm-test.ts b/packages/@expo/cli/src/utils/__tests__/npm-test.ts index 9e04b47f0b078..dc7fa0d3f1f57 100644 --- a/packages/@expo/cli/src/utils/__tests__/npm-test.ts +++ b/packages/@expo/cli/src/utils/__tests__/npm-test.ts @@ -1,4 +1,41 @@ -import { sanitizeNpmPackageName } from '../npm'; +import stream from 'stream'; + +import { extractNpmTarballAsync, sanitizeNpmPackageName } from '../npm'; + +jest.mock('tar', () => ({ + extract: jest.fn().mockImplementation(() => { + const { Writable } = jest.requireActual('stream'); + return new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }); + }), +})); + +describe(extractNpmTarballAsync, () => { + it('should return the checksum from a tarball stream', async () => { + // Create a dummy stream rather than a real tarball + const readableStream = stream.Readable.from(Buffer.from('Hello world!')); + + await expect( + extractNpmTarballAsync(readableStream, { + name: 'test', + cwd: '/tmp', + }) + ).resolves.toMatchInlineSnapshot(`"86fb269d190d2c85f6e0468ceca42a20"`); + + await expect( + extractNpmTarballAsync(readableStream, { + name: 'test', + cwd: '/tmp', + checksumAlgorithm: 'sha256', + }) + ).resolves.toMatchInlineSnapshot( + `"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"` + ); + }); +}); describe(sanitizeNpmPackageName, () => { it(`leaves valid names`, () => { diff --git a/packages/@expo/cli/src/utils/npm.ts b/packages/@expo/cli/src/utils/npm.ts index 4addd471cd0dd..521c4f91c49cd 100644 --- a/packages/@expo/cli/src/utils/npm.ts +++ b/packages/@expo/cli/src/utils/npm.ts @@ -1,9 +1,10 @@ import { JSONValue } from '@expo/json-file'; import spawnAsync from '@expo/spawn-async'; import assert from 'assert'; +import crypto from 'crypto'; import fs from 'fs'; import slugify from 'slugify'; -import { Stream } from 'stream'; +import { PassThrough, Stream } from 'stream'; import tar from 'tar'; import { promisify } from 'util'; @@ -97,19 +98,19 @@ const pipeline = promisify(Stream.pipeline); export async function downloadAndExtractNpmModuleAsync( npmName: string, props: ExtractProps -): Promise { +): Promise { const url = await getNpmUrlAsync(npmName); debug('Fetch from URL:', url); - await extractNpmTarballFromUrlAsync(url, props); + return await extractNpmTarballFromUrlAsync(url, props); } export async function extractLocalNpmTarballAsync( tarFilePath: string, props: ExtractProps -): Promise { +): Promise { const readStream = fs.createReadStream(tarFilePath); - await extractNpmTarballAsync(readStream, props); + return await extractNpmTarballAsync(readStream, props); } type ExtractProps = { @@ -117,6 +118,8 @@ type ExtractProps = { cwd: string; strip?: number; fileList?: string[]; + /** The checksum algorithm to use when verifying the tarball. */ + checksumAlgorithm?: string; }; async function createUrlStreamAsync(url: string) { @@ -131,20 +134,30 @@ async function createUrlStreamAsync(url: string) { export async function extractNpmTarballFromUrlAsync( url: string, props: ExtractProps -): Promise { - await extractNpmTarballAsync(await createUrlStreamAsync(url), props); +): Promise { + return await extractNpmTarballAsync(await createUrlStreamAsync(url), props); } +/** + * Extracts a tarball stream to a directory and returns the checksum of the tarball. + */ export async function extractNpmTarballAsync( stream: NodeJS.ReadableStream, props: ExtractProps -): Promise { +): Promise { const { cwd, strip, name, fileList = [] } = props; await ensureDirectoryAsync(cwd); + const hash = crypto.createHash(props.checksumAlgorithm ?? 'md5'); + const transformStream = new PassThrough(); + transformStream.on('data', (chunk) => { + hash.update(chunk); + }); + await pipeline( stream, + transformStream, tar.extract( { cwd, @@ -155,4 +168,6 @@ export async function extractNpmTarballAsync( fileList ) ); + + return hash.digest('hex'); }