UNPKG

xpm

Version:

The xPack project manager command line tool

864 lines (734 loc) 25.7 kB
/* * This file is part of the xPack project (http://xpack.github.io). * Copyright (c) 2017 Liviu Ionescu. All rights reserved. * * Permission to use, copy, modify, and/or distribute this software * for any purpose is hereby granted, under the terms of the MIT license. * * If a copy of the license was not distributed with this file, it can * be obtained from https://opensource.org/license/mit. */ 'use strict' /* eslint valid-jsdoc: "error" */ /* eslint max-len: [ "error", 80, { "ignoreUrls": true } ] */ // ---------------------------------------------------------------------------- // https://nodejs.org/docs/latest-v12.x/api/index.htm import assert from 'assert' import fs from 'fs' import os from 'os' import path from 'path' import util from 'util' import stream from 'stream' // ---------------------------------------------------------------------------- // https://www.npmjs.com/package/cacache import cacache from 'cacache' // https://www.npmjs.com/package/decompress import decompress from 'decompress' // https://www.npmjs.com/package/del import { deleteAsync } from 'del' // https://www.npmjs.com/package/https-proxy-agent import { HttpsProxyAgent } from 'https-proxy-agent' // https://www.npmjs.com/package/node-fetch import fetch from 'node-fetch' // https://www.npmjs.com/package/proxy-from-env import { getProxyForUrl } from 'proxy-from-env' // https://www.npmjs.com/package/semver import semver from 'semver' // ---------------------------------------------------------------------------- // import { CliError, CliErrorInput, CliExitCodes } // from '@ilg/cli-start-options' import cliStartOptionsCsj from '@ilg/cli-start-options' // ---------------------------------------------------------------------------- import { isString, isObject } from './functions.js' // ---------------------------------------------------------------------------- const { CliError, CliErrorInput, CliExitCodes } = cliStartOptionsCsj const fsPromises = fs.promises // ============================================================================ export class Xpack { constructor (xpackPath, context) { assert(xpackPath, 'mandatory xpackPath') this.xpackPath = xpackPath assert(context, 'mandatory context') this.context = context this.log = context.log // this.packageJson } // Throws if package.json not found. async readPackageJson () { const log = this.log const filePath = path.join(this.xpackPath, 'package.json') log.trace(`filePath: '${filePath}'`) try { const fileContent = await fsPromises.readFile(filePath) this.packageJson = JSON.parse(fileContent.toString()) // Name and version are not mandatory, they are needed // only when a package is published. return this.packageJson } catch (err) { log.trace(util.inspect(err)) throw new CliErrorInput( `package.json not found or malformed, the '${this.xpackPath}' ` + 'folder seems not an xpm package') } } processInheritance (packageJson = this.packageJson) { // Start with a shallow copy of the original. const newPackageJson = { ...packageJson } if (!newPackageJson.xpack) { return newPackageJson } // Add a shallow copy of the xpack property. newPackageJson.xpack = { ...packageJson.xpack } // There are no build configurations, done. if (!newPackageJson.xpack.buildConfigurations) { return newPackageJson } // Clear the destination build configurations. newPackageJson.xpack.buildConfigurations = {} const pendingConfigurations = {} for (const key of Object.keys(packageJson.xpack.buildConfigurations)) { this.processBuildConfigurationInheritanceRecursive({ buildConfigurationName: key, sourceBuildConfigurations: packageJson.xpack.buildConfigurations, destinationBuildConfigurations: newPackageJson.xpack.buildConfigurations, pendingConfigurations }) } return newPackageJson } processBuildConfigurationInheritanceRecursive ({ buildConfigurationName, sourceBuildConfigurations, destinationBuildConfigurations, pendingConfigurations }) { const log = this.log // Already processed. if (isObject(destinationBuildConfigurations[buildConfigurationName])) { return } const source = sourceBuildConfigurations[buildConfigurationName] const parentNames = [] if (source.inherit) { if (isString(source.inherit)) { parentNames.push(source.inherit) } else if (Array.isArray(source.inherit)) { for (const value of source.inherit) { if (isString(value)) { parentNames.push(value) } else { throw new CliErrorInput('inherit can be only string or' + ` string array (${buildConfigurationName})`) } } } else { throw new CliErrorInput('inherit can be only string or' + ` string array (${buildConfigurationName})`) } } if (parentNames.length === 0) { // Has no parents, copy as is. destinationBuildConfigurations[buildConfigurationName] = { ...source } return } if (pendingConfigurations[buildConfigurationName]) { throw new CliErrorInput( `circular inheritance in ${buildConfigurationName}`) } // Mark the configuration as pending, to catch circular references. pendingConfigurations[buildConfigurationName] = true const parents = [] for (const parentName of parentNames) { if (!isObject(sourceBuildConfigurations[parentName])) { throw new CliErrorInput( `inherit [${parentName}] not a valid buildConfiguration` + ` (${buildConfigurationName})`) } this.processBuildConfigurationInheritanceRecursive({ buildConfigurationName: parentName, sourceBuildConfigurations, destinationBuildConfigurations, pendingConfigurations }) // Remember as a parent. parents.push(destinationBuildConfigurations[parentName]) } // Add the source configuration at the end of the list. parents.push(sourceBuildConfigurations[buildConfigurationName]) const destination = { ...source, properties: {}, actions: {}, dependencies: {}, devDependencies: {} } for (const parent of parents) { if (parent.properties) { destination.properties = { ...destination.properties, ...parent.properties } } if (parent.actions) { destination.actions = { ...destination.actions, ...parent.actions } } if (parent.dependencies) { destination.dependencies = { ...destination.dependencies, ...parent.dependencies } } if (parent.devDependencies) { destination.devDependencies = { ...destination.devDependencies, ...parent.devDependencies } } } // Set the final value. destinationBuildConfigurations[buildConfigurationName] = destination pendingConfigurations[buildConfigurationName] = false if (log.isTrace()) { log.trace(buildConfigurationName + ':') log.trace(util.inspect(destination)) } } async rewritePackageJson (json = this.packageJson) { const log = this.log const jsonStr = JSON.stringify(json, null, 2) + '\n' const filePath = path.join(this.xpackPath, 'package.json') log.trace(`write filePath: '${filePath}'`) await fsPromises.writeFile(filePath, jsonStr) } getPlatformKey () { const context = this.context const config = context.config const platform = process.platform let arch = process.arch if (config.doForce32bit) { if (platform === 'win32' && arch === 'x64') { arch = 'ia32' } else if (platform === 'linux' && arch === 'x64') { arch = 'ia32' } else if (platform === 'linux' && arch === 'arm64') { arch = 'arm' } } return `${platform}-${arch}` } async downloadBinaries ({ packagePath, packageTmpPath, cacheFolderPath }) { const context = this.context const config = context.config const log = this.log log.trace(`checking '${packageTmpPath}'`) const json = await this.isFolderPackage(packageTmpPath) if (!json || !json.xpack) { log.debug('doesn\'t look like an xpm package, ' + 'package.json has no "xpack"') return } if (!json.xpack.binaries) { log.debug('doesn\'t look like a binary xpm package, ' + 'package.json has no "xpack.binaries"') return } if (!json.xpack.binaries.platforms) { log.debug('doesn\'t look like a binary xpm package, ' + 'package.json has no "xpack.binaries.platforms"') return } const platformKey = this.getPlatformKey() const platformKeyAliases = new Set() if (['linux-x32', 'linux-x86', 'linux-ia32'].includes(platformKey)) { platformKeyAliases.add('linux-x32') platformKeyAliases.add('linux-x86') platformKeyAliases.add('linux-ia32') // official } else if (['win32-x32', 'win32-x86', 'win32-ia32'].includes(platformKey)) { platformKeyAliases.add('win32-x32') platformKeyAliases.add('win32-x86') platformKeyAliases.add('win32-ia32') // official } else { platformKeyAliases.add(platformKey) } const platforms = json.xpack.binaries.platforms let platform for (const item of platformKeyAliases) { if (platforms[item]) { platform = platforms[item] break } } if (!platform) { throw new CliErrorInput(`platform ${platformKey} not supported`) } if (!json.xpack.binaries.baseUrl) { throw new CliErrorInput( 'missing "xpack.binaries.baseUrl" in package.json') } if (platform.skip) { log.warn('no binaries are available for this platform, command ignored') return } if (!platform.fileName) { throw new CliErrorInput( `missing xpack.binaries.platform[${platformKey}].fileName`) } // Prefer the platform specific URL, if available, otherwise // use the common URL. let fileUrl = platform.baseUrl || json.xpack.binaries.baseUrl if (!fileUrl.endsWith('/')) { fileUrl += '/' } fileUrl += platform.fileName let hashAlgorithm let hexSum if (platform.sha256) { hashAlgorithm = 'sha256' hexSum = platform.sha256 } else if (platform.sha512) { hashAlgorithm = 'sha512' hexSum = platform.sha512 } let integrityDigest if (hexSum) { const buff = Buffer.from(hexSum, 'hex') integrityDigest = `${hashAlgorithm}-${buff.toString('base64')}` } log.trace(`expected integrity digest ${integrityDigest} for ${hexSum}`) if (config.isDryRun) { log.info(`Pretend downloading ${fileUrl}...`) log.info(`Pretend extracting '${platform.fileName}'...`) return } const cacheKey = `xpm:binaries:${platform.fileName}` log.trace(`getting cacache info(${cacheFolderPath}, ${cacheKey})...`) // Debug only, to force the downloads. // await cacache.rm.entry(cacheFolderPath, cacheKey) let cacheInfo = await cacache.get.info(cacheFolderPath, cacheKey) if (!cacheInfo) { // If the cache has no idea of the desired file, proceed with // the download. log.info(`Downloading ${fileUrl}...`) const opts = {} if (integrityDigest) { // Enable hash checking. opts.integrity = integrityDigest } try { await this.cacheArchive(fileUrl, cacheFolderPath, cacheKey, opts) log.trace(`cache written for ${fileUrl}`) } catch (err) { log.trace(util.inspect(err)) // Do not throw yet, only display the error. log.info(err.message) if (os.platform() === 'win32') { log.info('If you have an aggressive antivirus, try to ' + 'reconfigure it, or temporarily disable it') } throw new CliErrorInput('download failed, quit') } // Update the cache info after downloading the file. cacheInfo = await cacache.get.info(cacheFolderPath, cacheKey) if (!cacheInfo) { throw new CliErrorInput('download failed, quit') } } log.trace(`cache path ${cacheInfo.path} for ${fileUrl}`) // The number of initial folder levels to skip. let skip = 0 if (json.xpack.binaries.skip) { try { skip = parseInt(json.xpack.binaries.skip) } catch (err) { } } log.trace(`skip ${skip} levels`) const contentFolderRelativePath = json.xpack.binaries.destination || '.content' const contentFolderPath = path.join(packagePath, contentFolderRelativePath) const contentFolderTmpPath = path.join(packageTmpPath, contentFolderRelativePath) log.trace(`del ${contentFolderTmpPath}`) await deleteAsync(contentFolderTmpPath, { force: true }) const cacheInfoPath = cacheInfo.path log.trace(`cacheInfoPath ${cacheInfoPath}`) let res = 0 // Currently this includes decompressTar(), decompressTarbz2(), // decompressTargz(), decompressUnzip(). log.info(`Extracting '${platform.fileName}'...`) res = await decompress(cacheInfoPath, contentFolderTmpPath, { strip: skip }) if (log.isVerbose()) { // The common value is self relative ./.content; remove the folder. const shownFolderRelativePath = contentFolderRelativePath.replace(/^\.\//, '') log.verbose( `${res.length} files extracted in ` + `'${json.version}/${shownFolderRelativePath}'`) } else { log.info( `${res.length} files => '${contentFolderPath}'`) } } // Returns nothing. async cacheArchive (url, cacheFolderPath, key, opts) { const log = this.log // https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md // https://github.com/node-fetch/node-fetch/blob/main/test/main.js // https://www.scrapingbee.com/blog/proxy-node-fetch/ // https://iproyal.com/blog/how-do-i-use-a-node-fetch-proxy/ let response let timeoutMillis = 1000 const proxyUrl = getProxyForUrl(url) log.trace(`proxyUrl ${proxyUrl}`) const maxRetry = 5 for (let retry = 0; retry < maxRetry; ++retry) { try { if (proxyUrl !== undefined && proxyUrl.length > 0) { const proxyAgent = new HttpsProxyAgent(proxyUrl) log.trace(`proxyAgent ${util.inspect(proxyAgent)} for ${url}`) response = await fetch(url, { agent: proxyAgent }) } else { response = await fetch(url) } } catch (err) { log.trace(util.inspect(err)) throw new CliError( `${err.message} in fetch ${url}`, CliExitCodes.ERROR.APPLICATION) } log.debug(`fetch.status ${response.status} ${url}`) log.trace(`fetch.statusText ${response.statusText} ${url}`) if (!response.ok) { break } // the HTTP response status was [200, 300). // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_success const pipelinePromise = util.promisify(stream.pipeline) log.trace(`create write stream for ${key}`) const cacacheWriteStream = cacache.put.stream(cacheFolderPath, key, opts) log.trace(`create pipeline for ${key}`) try { await pipelinePromise(response.body, cacacheWriteStream) // If no exception, everything must be ok. return } catch (err) { log.trace(util.inspect(err)) if (retry >= maxRetry) { throw new CliError( `${err.message} in pipeline ${url}`, CliExitCodes.ERROR.APPLICATION) } // For now retry on all errors during download. // TODO: identify non recoverable and quit. log.warn(`${err.message} while downloading ${url}, retrying...`) const tenPercent = timeoutMillis * 0.1 // +/- 10% // Math.random() * (max - min) + min const jitter = Math.floor( Math.random() * (tenPercent - (-tenPercent)) + (-tenPercent)) timeoutMillis = timeoutMillis + jitter log.debug(`timeoutMillis: ${timeoutMillis}`) const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)) await sleep(timeoutMillis) // 1 2 4 8 16... seconds timeoutMillis = timeoutMillis * 2 } } // res.status < 200 || res.status >= 300 (4xx, 5xx) // 1xx informational // 3xx: redirection messages // 4xx: client error // 5xx: server error // TODO: detect cases that can be retried. throw new CliError( `server returned ${response.status}: ${response.statusText} for ${key}`) } async skipRecursive (from, dest, skip) { if (skip > 0) { const children = await fsPromises.readdir(from) for (const child of children) { const newPath = path.join(from, child) await this.skipRecursive(newPath, dest, skip - 1) } } else { const children = await fsPromises.readdir(from) for (const child of children) { await fsPromises.rename(path.join(from, child), path.join(dest, child)) } } } async isFolderPackage (folderPath) { const jsonPath = path.join(folderPath, 'package.json') try { const fileContent = await fsPromises.readFile(jsonPath) assert(fileContent !== null) const json = JSON.parse(fileContent.toString()) if (json.name && json.version) { return json } } catch (err) { return null } return null } isPackage (json = this.packageJson) { return !!json } isXpack (json = this.packageJson) { return !!json && !!json.xpack } isBinaryXpack (json = this.packageJson) { // Since Nov. 2024, `executables` is preferred to `bin`. return !!json && !!json.xpack && (!!json.xpack.executables || !!json.xpack.bin) } isNodeModule (json = this.packageJson) { return !!json && !json.xpack } isBinaryNodeModule (json = this.packageJson) { return !!json && !json.xpack && !!json.bin } parsePackageSpecifier ({ packSpec }) { assert(packSpec) const log = this.log let scope let name let version if (packSpec.startsWith('@')) { const arr = packSpec.split('/') if (arr.length > 2) { throw new CliError( `'${packSpec}' not a package name`) } scope = arr[0] if (arr.length > 1) { const arr2 = arr[1].split('@') name = arr2[0] if (arr2.length > 1) { version = arr2[1] } } } else { const arr2 = packSpec.split('@') name = arr2[0] if (arr2.length > 1) { version = arr2[1] } } log.trace(`${packSpec} => ${scope || '?'} ${name || '?'} ${version || '?'}`) return { scope, name, version } } retrieveConfiguration ({ packageJson, configurationName }) { assert(packageJson) assert(packageJson.xpack) assert(configurationName) const log = this.log log.trace( `${this.constructor.name}.retrieveConfiguration('${configurationName}')`) // TODO: Legacy, remove it at some point. if (!packageJson.xpack.configurations && !packageJson.xpack.buildConfigurations) { throw new CliErrorInput( 'missing "xpack.buildConfigurations" property in package.json') } let configuration // Prefer `buildConfigurations`, but also accept `configurations`. if (packageJson.xpack.buildConfigurations) { configuration = packageJson.xpack.buildConfigurations[configurationName] } else if (packageJson.xpack.configurations) { // TODO: Legacy, remove it at some point. configuration = packageJson.xpack.configurations[configurationName] } if (!configuration) { throw new CliErrorInput( `missing "xpack.buildConfigurations.${configurationName}" ` + 'property in package.json') } return configuration } /** * @summary Perform substitutions for the build folder. * @param {*} options Multiple options * @returns {string|Promise} The relative path. */ async computeBuildFolderRelativePath ({ configurationName, configuration, liquidEngine, liquidMap }) { assert(configurationName) assert(configuration) assert(liquidEngine) assert(liquidMap) const log = this.log let buildFolderRelativePath = liquidMap.properties.buildFolderRelativePath if (buildFolderRelativePath) { // If already defined by the user, perform substitutions. try { buildFolderRelativePath = await liquidEngine.performSubstitutions( buildFolderRelativePath, liquidMap) } catch (err) { log.trace(util.inspect(err)) throw new CliError(err.message) } } else { // If not defined by the user, suggest a default and warn. buildFolderRelativePath = path.join('build', configurationName) liquidMap.properties.buildFolderRelativePath = buildFolderRelativePath log.warn('neither "configuration.properties.buildFolderRelativePath" ' + 'nor "xpack.properties.buildFolderRelativePath" were found in ' + 'package.json, using default ' + `"${buildFolderRelativePath}"...`) } log.trace(`buildFolderRelativePath: ${buildFolderRelativePath}`) return buildFolderRelativePath } // -------------------------------------------------------------------------- async checkMinimumXpmRequired (packageJson) { const context = this.context const log = this.log log.trace(`${this.constructor.name}.checkMinimumXpmRequired()`) if (!packageJson) { // Not in a package. return undefined } if (!packageJson.xpack || !packageJson.xpack.minimumXpmRequired) { log.trace('minimumXpmRequired not used, no checks') return undefined } // Remove the pre-release part. const minimumXpmRequired = semver.clean( packageJson.xpack.minimumXpmRequired.replace(/-.*$/, '')) log.trace(`minimumXpmRequired: ${minimumXpmRequired}`) log.trace(context.rootPath) const json = await this.isFolderPackage(context.rootPath) log.trace(json.version) // Remove the pre-release part. const xpmVersion = semver.clean(json.version.replace(/-.*$/, '')) if (semver.lt(xpmVersion, minimumXpmRequired)) { throw new CliError( `package '${packageJson.name}' requires xpm v${minimumXpmRequired} ` + 'or later, please update', CliExitCodes.ERROR.PREREQUISITES) } // Check passed. return minimumXpmRequired } } // ============================================================================ export class ManifestIds { constructor (manifest, policies) { assert(policies) this.policies = policies if (manifest._id) { // If pacote returns an ID, it is considered more trustworthy, // although it probably comes from the same name & version fields. if (manifest._id.startsWith('@')) { const parts = manifest._id.split('/') this.scope = parts[0] const parts2 = parts[1].split('@') this.name = parts2[0] this.version = parts2[1] || manifest.version } else { const parts = manifest._id.split('@') this.name = parts[0] this.version = parts[1] || manifest.version } } else { // Without ID, use the package.json name & version. assert(manifest.name) assert(manifest.version) if (manifest.name.startsWith('@')) { const arr = manifest.name.split('/') this.scope = arr[0] this.name = arr[1] this.version = manifest.version } else { this.name = manifest.name this.version = manifest.version } } this.from_ = manifest._from // TODO: validate scope, name & version. } getScopedName () { if (this.scope) { return `${this.scope}/${this.name}` } else { return `${this.name}` } } getPath () { if (this.scope) { return path.join(this.scope, this.name, this.version) } else { return path.join( this.name, this.version) } } getPosixPath () { if (this.scope) { return path.posix.join(this.scope, this.name, this.version) } else { return path.posix.join(this.name, this.version) } } getFullName () { if (this.scope) { return `${this.scope}/${this.name}@${this.version}` } else { return `${this.name}@${this.version}` } } getFolderName () { if (this.scope) { if (this.policies.nonHierarchicalLocalXpacksFolder) { // Linearise the name into a single folder. return `${this.scope.slice(1)}-${this.name}` } else { // Use the same hierarchical folders as npm. return path.join(this.scope, this.name) } } else { return `${this.name}` } } getPacoteFrom () { return this.from_ ? this.from_ : this.getFullName() } } // ---------------------------------------------------------------------------- // Node.js specific export definitions. // By default, `module.exports = {}`. // The Test class is added as a property of this object. // module.exports.Xpack = Xpack // module.exports.ManifestIds = ManifestIds // In ES6, it would be: // export class Xpack { ... } // ... // import { Xpack } from '../utils/xpack.js' // ----------------------------------------------------------------------------