// ***************************************************************************** // Copyright (C) 2020 Ericsson and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /* eslint-disable @typescript-eslint/no-explicit-any */ import { OVSXApiFilterImpl, OVSXClient, VSXTargetPlatform } from '@theia/ovsx-client'; import * as chalk from 'chalk'; import * as decompress from 'decompress'; import { promises as fs } from 'fs'; import * as path from 'path'; import * as temp from 'temp'; import { DEFAULT_SUPPORTED_API_VERSION } from '@theia/application-package/lib/api'; import { RequestContext, RequestService } from '@theia/request'; import { RateLimiter } from 'limiter'; import escapeStringRegexp = require('escape-string-regexp'); temp.track(); /** * Available options when downloading. */ export interface DownloadPluginsOptions { /** * Determines if a plugin should be unpacked. * Defaults to `false`. */ packed?: boolean; /** * Determines if failures while downloading plugins should be ignored. * Defaults to `false`. */ ignoreErrors?: boolean; /** * The supported vscode API version. * Used to determine extension compatibility. */ apiVersion?: string; /** * Fetch plugins in parallel */ parallel?: boolean; } interface PluginDownload { id: string, downloadUrl: string, version?: string | undefined } export default async function downloadPlugins( ovsxClient: OVSXClient, rateLimiter: RateLimiter, requestService: RequestService, options: DownloadPluginsOptions = {} ): Promise { const { packed = false, ignoreErrors = false, apiVersion = DEFAULT_SUPPORTED_API_VERSION, parallel = true } = options; const apiFilter = new OVSXApiFilterImpl(ovsxClient, apiVersion); // Collect the list of failures to be appended at the end of the script. const failures: string[] = []; // Resolve the `package.json` at the current working directory. const pck = JSON.parse(await fs.readFile(path.resolve('package.json'), 'utf8')); // Resolve the directory for which to download the plugins. const pluginsDir = pck.theiaPluginsDir || 'plugins'; // Excluded extension ids. const excludedIds = new Set(pck.theiaPluginsExcludeIds || []); const parallelOrSequence = async (tasks: (() => unknown)[]) => { if (parallel) { await Promise.all(tasks.map(task => task())); } else { for (const task of tasks) { await task(); } } }; // Downloader wrapper const downloadPlugin = async (plugin: PluginDownload): Promise => { await downloadPluginAsync(requestService, rateLimiter, failures, plugin.id, plugin.downloadUrl, pluginsDir, packed, excludedIds, plugin.version); }; const downloader = async (plugins: PluginDownload[]) => { await parallelOrSequence(plugins.map(plugin => () => downloadPlugin(plugin))); }; await fs.mkdir(pluginsDir, { recursive: true }); if (!pck.theiaPlugins) { console.log(chalk.red('error: missing mandatory \'theiaPlugins\' property.')); return; } try { console.warn('--- downloading plugins ---'); // Download the raw plugins defined by the `theiaPlugins` property. // This will include both "normal" plugins as well as "extension packs". const pluginsToDownload = Object.entries(pck.theiaPlugins) .filter((entry: [string, unknown]): entry is [string, string] => typeof entry[1] === 'string') .map(([id, url]) => ({ id, downloadUrl: resolveDownloadUrlPlaceholders(url) })); await downloader(pluginsToDownload); const handleDependencyList = async (dependencies: (string | string[])[]) => { // De-duplicate extension ids to only download each once: const ids = new Set(dependencies.flat()); await parallelOrSequence(Array.from(ids, id => async () => { try { await rateLimiter.removeTokens(1); const extension = await apiFilter.findLatestCompatibleExtension({ extensionId: id, includeAllVersions: true, targetPlatform }); const version = extension?.version; const downloadUrl = extension?.files.download; if (downloadUrl) { await rateLimiter.removeTokens(1); await downloadPlugin({ id, downloadUrl, version }); } else { failures.push(`No download url for extension pack ${id} (${version})`); } } catch (err) { console.error(err); failures.push(err.message); } })); }; console.warn('--- collecting extension-packs ---'); const extensionPacks = await collectExtensionPacks(pluginsDir, excludedIds); if (extensionPacks.size > 0) { console.warn(`--- resolving ${extensionPacks.size} extension-packs ---`); await handleDependencyList(Array.from(extensionPacks.values())); } console.warn('--- collecting extension dependencies ---'); const pluginDependencies = await collectPluginDependencies(pluginsDir, excludedIds); if (pluginDependencies.length > 0) { console.warn(`--- resolving ${pluginDependencies.length} extension dependencies ---`); await handleDependencyList(pluginDependencies); } } finally { temp.cleanupSync(); } for (const failure of failures) { console.error(failure); } if (!ignoreErrors && failures.length > 0) { throw new Error('Errors downloading some plugins. To make these errors non fatal, re-run with --ignore-errors'); } } const targetPlatform = `${process.platform}-${process.arch}` as VSXTargetPlatform; const placeholders: Record = { targetPlatform }; function resolveDownloadUrlPlaceholders(url: string): string { for (const [name, value] of Object.entries(placeholders)) { url = url.replace(new RegExp(escapeStringRegexp(`\${${name}}`), 'g'), value); } return url; } /** * Downloads a plugin, will make multiple attempts before actually failing. * @param requestService * @param failures reference to an array storing all failures. * @param plugin plugin short name. * @param pluginUrl url to download the plugin at. * @param target where to download the plugin in. * @param packed whether to decompress or not. */ async function downloadPluginAsync( requestService: RequestService, rateLimiter: RateLimiter, failures: string[], plugin: string, pluginUrl: string, pluginsDir: string, packed: boolean, excludedIds: Set, version?: string ): Promise { if (!plugin) { return; } let fileExt: string; if (pluginUrl.endsWith('tar.gz')) { fileExt = '.tar.gz'; } else if (pluginUrl.endsWith('vsix')) { fileExt = '.vsix'; } else if (pluginUrl.endsWith('theia')) { fileExt = '.theia'; // theia plugins. } else { failures.push(chalk.red(`error: '${plugin}' has an unsupported file type: '${pluginUrl}'`)); return; } const targetPath = path.resolve(pluginsDir, `${plugin}${packed === true ? fileExt : ''}`); // Skip plugins which have previously been downloaded. if (await isDownloaded(targetPath)) { console.warn('- ' + plugin + ': already downloaded - skipping'); return; } const maxAttempts = 5; const retryDelay = 2000; let attempts: number; let lastError: Error | undefined; let response: RequestContext | undefined; for (attempts = 0; attempts < maxAttempts; attempts++) { if (attempts > 0) { await new Promise(resolve => setTimeout(resolve, retryDelay)); } lastError = undefined; try { await rateLimiter.removeTokens(1); response = await requestService.request({ url: pluginUrl }); } catch (error) { lastError = error; continue; } const status = response.res.statusCode; const retry = status && (status === 429 || status === 439 || status >= 500); if (!retry) { break; } } if (lastError) { failures.push(chalk.red(`x ${plugin}: failed to download, last error:\n ${lastError}`)); return; } if (typeof response === 'undefined') { failures.push(chalk.red(`x ${plugin}: failed to download (unknown reason)`)); return; } if (response.res.statusCode !== 200) { failures.push(chalk.red(`x ${plugin}: failed to download with: ${response.res.statusCode}`)); return; } if ((fileExt === '.vsix' || fileExt === '.theia')) { if (packed) { // Download .vsix without decompressing. await fs.writeFile(targetPath, response.buffer); } else { await decompressVsix(targetPath, response.buffer); } console.warn(chalk.green(`+ ${plugin}${version ? `@${version}` : ''}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`)); } else if (fileExt === '.tar.gz') { // Write the downloaded tar.gz to a temporary file for decompression. const tempFile = temp.path('theia-plugin-download'); await fs.writeFile(tempFile, response.buffer); // Decompress to inspect archive contents and determine handling strategy. const files = await decompress(tempFile); // Check if the archive is a bundle containing only .vsix files. const allVsix = files.length > 0 && files.every(file => file.path.endsWith('.vsix')); if (allVsix) { // Handle pure vsix bundle: process each vsix individually. for (const file of files) { const vsixName = path.basename(file.path); const pluginId = vsixName.replace(/\.vsix$/, ''); if (excludedIds.has(pluginId)) { console.log(chalk.yellow(`'${pluginId}' referred to by '${plugin}' (tar.gz) is excluded because of 'theiaPluginsExcludeIds'`)); continue; } const vsixTargetPath = packed ? path.join(pluginsDir, vsixName) : path.join(pluginsDir, pluginId); if (await isDownloaded(vsixTargetPath)) { console.warn('- ' + pluginId + ': already downloaded - skipping'); continue; } if (packed) { // Download .vsix without decompressing. await fs.writeFile(vsixTargetPath, file.data); } else { await decompressVsix(vsixTargetPath, file.data); } console.warn(chalk.green(`+ ${pluginId}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`)); } } else { // Handle regular tar.gz: decompress directly to target directory. await fs.mkdir(targetPath, { recursive: true }); await decompress(tempFile, targetPath); console.warn(chalk.green(`+ ${plugin}${version ? `@${version}` : ''}: downloaded successfully ${attempts > 1 ? `(after ${attempts} attempts)` : ''}`)); } await fs.unlink(tempFile); } } /** * Decompresses a VSIX plugin archive to a target directory. * * Creates the target directory if it doesn't exist, writes the buffer content * to a temporary file, and then extracts the archive contents to the target path. * * @param targetPath the directory path where the VSIX contents will be extracted. * @param buffer the VSIX file content as a binary buffer or string. */ async function decompressVsix(targetPath: string, buffer: Uint8Array | string): Promise { await fs.mkdir(targetPath, { recursive: true }); const tempFile = temp.path('theia-plugin-download'); await fs.writeFile(tempFile, buffer); await decompress(tempFile, targetPath); await fs.unlink(tempFile); } /** * Determine if the resource for the given path is already downloaded. * @param filePath the resource path. * * @returns `true` if the resource is already downloaded, else `false`. */ async function isDownloaded(filePath: string): Promise { return fs.stat(filePath).then(() => true, () => false); } /** * Walk the plugin directory and collect available extension paths. * @param pluginDir the plugin directory. * @returns the list of all available extension paths. */ async function collectPackageJsonPaths(pluginDir: string): Promise { const packageJsonPathList: string[] = []; const files = await fs.readdir(pluginDir); // Recursively fetch the list of extension `package.json` files. for (const file of files) { const filePath = path.join(pluginDir, file); if ((await fs.stat(filePath)).isDirectory()) { packageJsonPathList.push(...await collectPackageJsonPaths(filePath)); } else if (path.basename(filePath) === 'package.json' && !path.dirname(filePath).includes('node_modules')) { packageJsonPathList.push(filePath); } } return packageJsonPathList; } /** * Get the mapping of extension-pack paths and their included plugin ids. * - If an extension-pack references an explicitly excluded `id` the `id` will be omitted. * @param pluginDir the plugin directory. * @param excludedIds the list of plugin ids to exclude. * @returns the mapping of extension-pack paths and their included plugin ids. */ async function collectExtensionPacks(pluginDir: string, excludedIds: Set): Promise> { const extensionPackPaths = new Map(); const packageJsonPaths = await collectPackageJsonPaths(pluginDir); await Promise.all(packageJsonPaths.map(async packageJsonPath => { const json = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); const extensionPack: unknown = json.extensionPack; if (Array.isArray(extensionPack)) { extensionPackPaths.set(packageJsonPath, extensionPack.filter(id => { if (excludedIds.has(id)) { console.log(chalk.yellow(`'${id}' referred to by '${json.name}' (ext pack) is excluded because of 'theiaPluginsExcludeIds'`)); return false; // remove } return true; // keep })); } })); return extensionPackPaths; } /** * Get the mapping of paths and their included plugin ids. * - If an extension-pack references an explicitly excluded `id` the `id` will be omitted. * @param pluginDir the plugin directory. * @param excludedIds the list of plugin ids to exclude. * @returns the mapping of extension-pack paths and their included plugin ids. */ async function collectPluginDependencies(pluginDir: string, excludedIds: Set): Promise { const dependencyIds: string[] = []; const packageJsonPaths = await collectPackageJsonPaths(pluginDir); await Promise.all(packageJsonPaths.map(async packageJsonPath => { const json = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); const extensionDependencies: unknown = json.extensionDependencies; if (Array.isArray(extensionDependencies)) { for (const dependency of extensionDependencies) { if (excludedIds.has(dependency)) { console.log(chalk.yellow(`'${dependency}' referred to by '${json.name}' is excluded because of 'theiaPluginsExcludeIds'`)); } else { dependencyIds.push(dependency); } } } })); return dependencyIds; }