Files
vue-cli/packages/@vue/cli/lib/util/ProjectPackageManager.js
Haoqun Jiang e1eba098ae fix: discard NODE_ENV when installing project dependencies (#6224)
Avoid empty `node_modules` when the user has set `NODE_ENV` to
`production` in the shell environment.

In the long run we should have a more comprehensive preflight check
for execution environment though.
2021-01-21 16:31:19 +08:00

434 lines
13 KiB
JavaScript

const fs = require('fs-extra')
const path = require('path')
const ini = require('ini')
const minimist = require('minimist')
const LRU = require('lru-cache')
const stripAnsi = require('strip-ansi')
const {
chalk,
execa,
semver,
request,
resolvePkg,
loadModule,
hasYarn,
hasProjectYarn,
hasPnpm3OrLater,
hasPnpmVersionOrLater,
hasProjectPnpm,
hasProjectNpm,
isOfficialPlugin,
resolvePluginId,
log,
warn,
error
} = require('@vue/cli-shared-utils')
const { loadOptions } = require('../options')
const { executeCommand } = require('./executeCommand')
const registries = require('./registries')
const shouldUseTaobao = require('./shouldUseTaobao')
const metadataCache = new LRU({
max: 200,
maxAge: 1000 * 60 * 30 // 30 min.
})
const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG
const SUPPORTED_PACKAGE_MANAGERS = ['yarn', 'pnpm', 'npm']
const PACKAGE_MANAGER_PNPM4_CONFIG = {
install: ['install', '--reporter', 'silent', '--shamefully-hoist'],
add: ['install', '--reporter', 'silent', '--shamefully-hoist'],
upgrade: ['update', '--reporter', 'silent'],
remove: ['uninstall', '--reporter', 'silent']
}
const PACKAGE_MANAGER_PNPM3_CONFIG = {
install: ['install', '--loglevel', 'error', '--shamefully-flatten'],
add: ['install', '--loglevel', 'error', '--shamefully-flatten'],
upgrade: ['update', '--loglevel', 'error'],
remove: ['uninstall', '--loglevel', 'error']
}
const PACKAGE_MANAGER_CONFIG = {
npm: {
install: ['install', '--loglevel', 'error'],
add: ['install', '--loglevel', 'error'],
upgrade: ['update', '--loglevel', 'error'],
remove: ['uninstall', '--loglevel', 'error']
},
pnpm: hasPnpmVersionOrLater('4.0.0') ? PACKAGE_MANAGER_PNPM4_CONFIG : PACKAGE_MANAGER_PNPM3_CONFIG,
yarn: {
install: [],
add: ['add'],
upgrade: ['upgrade'],
remove: ['remove']
}
}
// extract the package name 'xx' from the format 'xx@1.1'
function stripVersion (packageName) {
const nameRegExp = /^(@?[^@]+)(@.*)?$/
const result = packageName.match(nameRegExp)
if (!result) {
throw new Error(`Invalid package name ${packageName}`)
}
return result[1]
}
// extract the package scope from the full package name
// the result includes the initial @ character
function extractPackageScope (packageName) {
const scopedNameRegExp = /^(@[^/]+)\/.*$/
const result = packageName.match(scopedNameRegExp)
if (!result) {
return undefined
}
return result[1]
}
class PackageManager {
constructor ({ context, forcePackageManager } = {}) {
this.context = context || process.cwd()
this._registries = {}
if (forcePackageManager) {
this.bin = forcePackageManager
} else if (context) {
if (hasProjectYarn(context)) {
this.bin = 'yarn'
} else if (hasProjectPnpm(context)) {
this.bin = 'pnpm'
} else if (hasProjectNpm(context)) {
this.bin = 'npm'
}
}
// if no package managers specified, and no lockfile exists
if (!this.bin) {
this.bin = loadOptions().packageManager || (hasYarn() ? 'yarn' : hasPnpm3OrLater() ? 'pnpm' : 'npm')
}
if (this.bin === 'npm') {
// npm doesn't support package aliases until v6.9
const MIN_SUPPORTED_NPM_VERSION = '6.9.0'
const npmVersion = stripAnsi(execa.sync('npm', ['--version']).stdout)
if (semver.lt(npmVersion, MIN_SUPPORTED_NPM_VERSION)) {
throw new Error(
'You are using an outdated version of NPM.\n' +
'It does not support some core functionalities of Vue CLI.\n' +
'Please upgrade your NPM version.'
)
}
if (semver.gte(npmVersion, '7.0.0')) {
this.needsPeerDepsFix = true
}
}
if (!SUPPORTED_PACKAGE_MANAGERS.includes(this.bin)) {
log()
warn(
`The package manager ${chalk.red(this.bin)} is ${chalk.red('not officially supported')}.\n` +
`It will be treated like ${chalk.cyan('npm')}, but compatibility issues may occur.\n` +
`See if you can use ${chalk.cyan('--registry')} instead.`
)
PACKAGE_MANAGER_CONFIG[this.bin] = PACKAGE_MANAGER_CONFIG.npm
}
// Plugin may be located in another location if `resolveFrom` presents.
const projectPkg = resolvePkg(this.context)
const resolveFrom = projectPkg && projectPkg.vuePlugins && projectPkg.vuePlugins.resolveFrom
// Logically, `resolveFrom` and `context` are distinct fields.
// But in Vue CLI we only care about plugins.
// So it is fine to let all other operations take place in the `resolveFrom` directory.
if (resolveFrom) {
this.context = path.resolve(context, resolveFrom)
}
}
// Any command that implemented registry-related feature should support
// `-r` / `--registry` option
async getRegistry (scope) {
const cacheKey = scope || ''
if (this._registries[cacheKey]) {
return this._registries[cacheKey]
}
const args = minimist(process.argv, {
alias: {
r: 'registry'
}
})
let registry
if (args.registry) {
registry = args.registry
} else if (!process.env.VUE_CLI_TEST && await shouldUseTaobao(this.bin)) {
registry = registries.taobao
} else {
try {
if (scope) {
registry = (await execa(this.bin, ['config', 'get', scope + ':registry'])).stdout
}
if (!registry || registry === 'undefined') {
registry = (await execa(this.bin, ['config', 'get', 'registry'])).stdout
}
} catch (e) {
// Yarn 2 uses `npmRegistryServer` instead of `registry`
registry = (await execa(this.bin, ['config', 'get', 'npmRegistryServer'])).stdout
}
}
this._registries[cacheKey] = stripAnsi(registry).trim()
return this._registries[cacheKey]
}
async getAuthConfig (scope) {
// get npmrc (https://docs.npmjs.com/configuring-npm/npmrc.html#files)
const possibleRcPaths = [
path.resolve(this.context, '.npmrc'),
path.resolve(require('os').homedir(), '.npmrc')
]
if (process.env.PREFIX) {
possibleRcPaths.push(path.resolve(process.env.PREFIX, '/etc/npmrc'))
}
// there's also a '/path/to/npm/npmrc', skipped for simplicity of implementation
let npmConfig = {}
for (const loc of possibleRcPaths) {
if (fs.existsSync(loc)) {
try {
// the closer config file (the one with lower index) takes higher precedence
npmConfig = Object.assign({}, ini.parse(fs.readFileSync(loc, 'utf-8')), npmConfig)
} catch (e) {
// in case of file permission issues, etc.
}
}
}
const registry = await this.getRegistry(scope)
const registryWithoutProtocol = registry
.replace(/https?:/, '') // remove leading protocol
.replace(/([^/])$/, '$1/') // ensure ending with slash
const authTokenKey = `${registryWithoutProtocol}:_authToken`
const authUsernameKey = `${registryWithoutProtocol}:username`
const authPasswordKey = `${registryWithoutProtocol}:_password`
const auth = {}
if (authTokenKey in npmConfig) {
auth.token = npmConfig[authTokenKey]
}
if (authPasswordKey in npmConfig) {
auth.username = npmConfig[authUsernameKey]
auth.password = Buffer.from(npmConfig[authPasswordKey], 'base64').toString()
}
return auth
}
async setRegistryEnvs () {
const registry = await this.getRegistry()
process.env.npm_config_registry = registry
process.env.YARN_NPM_REGISTRY_SERVER = registry
this.setBinaryMirrors()
}
// set mirror urls for users in china
async setBinaryMirrors () {
const registry = await this.getRegistry()
if (registry !== registries.taobao) {
return
}
try {
// chromedriver, etc.
const binaryMirrorConfigMetadata = await this.getMetadata('binary-mirror-config', { full: true })
const latest = binaryMirrorConfigMetadata['dist-tags'] && binaryMirrorConfigMetadata['dist-tags'].latest
const mirrors = binaryMirrorConfigMetadata.versions[latest].mirrors.china
for (const key in mirrors.ENVS) {
process.env[key] = mirrors.ENVS[key]
}
// Cypress
const cypressMirror = mirrors.cypress
const defaultPlatforms = {
darwin: 'osx64',
linux: 'linux64',
win32: 'win64'
}
const platforms = cypressMirror.newPlatforms || defaultPlatforms
const targetPlatform = platforms[require('os').platform()]
// Do not override user-defined env variable
// Because we may construct a wrong download url and an escape hatch is necessary
if (targetPlatform && !process.env.CYPRESS_INSTALL_BINARY) {
const projectPkg = resolvePkg(this.context)
if (projectPkg && projectPkg.devDependencies && projectPkg.devDependencies.cypress) {
const wantedCypressVersion = await this.getRemoteVersion('cypress', projectPkg.devDependencies.cypress)
process.env.CYPRESS_INSTALL_BINARY =
`${cypressMirror.host}/${wantedCypressVersion}/${targetPlatform}/cypress.zip`
}
}
} catch (e) {
// get binary mirror config failed
}
}
// https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md
async getMetadata (packageName, { full = false } = {}) {
const scope = extractPackageScope(packageName)
const registry = await this.getRegistry(scope)
const metadataKey = `${this.bin}-${registry}-${packageName}`
let metadata = metadataCache.get(metadataKey)
if (metadata) {
return metadata
}
const headers = {}
if (!full) {
headers.Accept = 'application/vnd.npm.install-v1+json;q=1.0, application/json;q=0.9, */*;q=0.8'
}
const authConfig = await this.getAuthConfig(scope)
if ('password' in authConfig) {
const credentials = Buffer.from(`${authConfig.username}:${authConfig.password}`).toString('base64')
headers.Authorization = `Basic ${credentials}`
}
if ('token' in authConfig) {
headers.Authorization = `Bearer ${authConfig.token}`
}
const url = `${registry.replace(/\/$/g, '')}/${packageName}`
try {
metadata = (await request.get(url, { headers }))
if (metadata.error) {
throw new Error(metadata.error)
}
metadataCache.set(metadataKey, metadata)
return metadata
} catch (e) {
error(`Failed to get response from ${url}`)
throw e
}
}
async getRemoteVersion (packageName, versionRange = 'latest') {
const metadata = await this.getMetadata(packageName)
if (Object.keys(metadata['dist-tags']).includes(versionRange)) {
return metadata['dist-tags'][versionRange]
}
const versions = Array.isArray(metadata.versions) ? metadata.versions : Object.keys(metadata.versions)
return semver.maxSatisfying(versions, versionRange)
}
getInstalledVersion (packageName) {
// for first level deps, read package.json directly is way faster than `npm list`
try {
const packageJson = loadModule(`${packageName}/package.json`, this.context, true)
return packageJson.version
} catch (e) {}
}
async runCommand (command, args) {
const prevNodeEnv = process.env.NODE_ENV
// In the use case of Vue CLI, when installing dependencies,
// the `NODE_ENV` environment variable does no good;
// it only confuses users by skipping dev deps (when set to `production`).
delete process.env.NODE_ENV
await this.setRegistryEnvs()
await executeCommand(
this.bin,
[
...PACKAGE_MANAGER_CONFIG[this.bin][command],
...(args || [])
],
this.context
)
if (prevNodeEnv) {
process.env.NODE_ENV = prevNodeEnv
}
}
async install () {
const args = []
if (this.needsPeerDepsFix) {
args.push('--legacy-peer-deps')
}
if (process.env.VUE_CLI_TEST) {
args.push('--silent', '--no-progress')
}
return await this.runCommand('install', args)
}
async add (packageName, {
tilde = false,
dev = true
} = {}) {
const args = dev ? ['-D'] : []
if (tilde) {
if (this.bin === 'yarn') {
args.push('--tilde')
} else {
process.env.npm_config_save_prefix = '~'
}
}
if (this.needsPeerDepsFix) {
args.push('--legacy-peer-deps')
}
return await this.runCommand('add', [packageName, ...args])
}
async remove (packageName) {
return await this.runCommand('remove', [packageName])
}
async upgrade (packageName) {
// manage multiple packages separated by spaces
const packageNamesArray = []
for (const packname of packageName.split(' ')) {
const realname = stripVersion(packname)
if (
isTestOrDebug &&
(packname === '@vue/cli-service' || isOfficialPlugin(resolvePluginId(realname)))
) {
// link packages in current repo for test
const src = path.resolve(__dirname, `../../../../${realname}`)
const dest = path.join(this.context, 'node_modules', realname)
await fs.remove(dest)
await fs.symlink(src, dest, 'dir')
} else {
packageNamesArray.push(packname)
}
}
if (packageNamesArray.length) return await this.runCommand('add', packageNamesArray)
}
}
module.exports = PackageManager