xpm
Version:
The xPack project manager command line tool
1,621 lines (1,403 loc) • 67 kB
JavaScript
/*
* 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 } ] */
// ----------------------------------------------------------------------------
/**
* The `xpm install <options> ...` command implementation.
*/
// ----------------------------------------------------------------------------
// https://nodejs.org/docs/latest-v12.x/api/index.htm
import assert from 'assert'
import fs from 'fs'
import path from 'path'
import os from 'os'
import util from 'util'
// ----------------------------------------------------------------------------
// https://www.npmjs.com/package/@npmcli/arborist
import { Arborist } from '@npmcli/arborist'
// https://www.npmjs.com/package/copy-file
import { copyFile } from 'copy-file'
// https://www.npmjs.com/package/del
import { deleteAsync } from 'del'
// https://www.npmjs.com/package/make-dir
import { makeDirectory } from 'make-dir'
// https://www.npmjs.com/package/pacote
import pacote from 'pacote'
// https://www.npmjs.com/package/semver
import semver from 'semver'
// ----------------------------------------------------------------------------
// ES6: `import { CliCommand, CliExitCodes, CliError } from 'cli-start-options'
import cliStartOptionsCsj from '@ilg/cli-start-options'
// https://www.npmjs.com/package/cmd-shim
// const cmdShim = require('cmd-shim')
// Needed for the patch to generate absolute paths.
// https://www.npmjs.com/@xpack/cmd-shim
import cmdShim from '@xpack/cmd-shim'
// https://www.npmjs.com/package/@xpack/xpm-liquid
import { XpmLiquid } from '@xpack/xpm-liquid'
// ----------------------------------------------------------------------------
import { FsUtils } from '../utils/fs-utils.js'
import { GlobalConfig } from '../utils/global-config.js'
import { ManifestIds, Xpack } from '../utils/xpack.js'
import { Policies } from '../utils/policies.js'
import { Spawn } from '../../lib/utils/spawn.js'
import { isString, isObject } from '../../lib/utils/functions.js'
// ----------------------------------------------------------------------------
const { CliCommand, CliExitCodes, CliError, CliErrorInput } = cliStartOptionsCsj
const fsPromises = fs.promises
// Shims with paths relative to local xpacks fail in subtle ways, for example
// arm-none-eabi-g++ cannot find <bits/c++-allocator.h>.
const useAbsolutePathsWindows = true
// ============================================================================
export class Install extends CliCommand {
// --------------------------------------------------------------------------
/**
* @summary Constructor, to set help definitions.
*
* @param {Object} context Reference to a context.
*/
constructor (context) {
super(context)
// Title displayed with the help message.
this.title = 'xPack project manager - install package(s)'
this.optionGroups = [
{
title: 'Install options',
// Extra arguments.
postOptions: '[[@<scope>/]<name>[@<version]|<github_name>/<repo>]...',
optionDefs: [
{
options: ['-g', '--global'],
init: ({ config }) => {
config.isGlobal = false
},
action: ({ config }) => {
config.isGlobal = true
},
msg: 'Install the package globally in the home folder',
isOptional: true
},
// {
// options: ['-sy', '--system'],
// init: ({ config }) => {
// config.isSystem = false
// },
// action: ({ config }) => {
// config.isSystem = true
// },
// msg: 'Install the package in a system folder',
// isOptional: true
// },
{
options: ['-f', '--force'],
init: ({ config }) => {
config.doForce = false
},
action: ({ config }) => {
config.doForce = true
},
msg: 'Force install over existing package',
isOptional: true
},
{
options: ['-32', '--force-32bit'],
init: ({ config }) => {
config.doForce32bit = false
},
action: ({ config }) => {
config.doForce32bit = true
},
msg: 'Force install 32-bit binaries',
isOptional: true
},
{
options: ['-c', '--config'],
init: ({ config }) => {
config.configurationName = undefined
},
action: ({ config }, val) => {
config.configurationName = val.trim()
},
msg: 'Install configuration specific dependencies',
param: 'config_name',
isOptional: true
},
{
options: ['-a', '--all-configs'],
init: ({ config }) => {
config.isAllConfigs = false
},
action: ({ config }) => {
config.isAllConfigs = true
},
msg: 'Install dependencies for all configurations',
isOptional: true
},
{
options: ['-n', '--dry-run'],
init: ({ config }) => {
config.isDryRun = false
},
action: ({ config }) => {
config.isDryRun = true
},
msg: 'Pretend to install the package(s)',
isOptional: true
},
{
options: ['-P', '--save-prod'],
init: ({ config }) => {
config.doSaveProd = false
},
action: ({ config }) => {
config.doSaveProd = true
},
msg: 'Save to dependencies; default unless -D or -O',
isOptional: true
},
{
options: ['--no-save'],
init: ({ config }) => {
config.doNotSave = false
},
action: ({ config }) => {
config.doNotSave = true
},
msg: 'Prevent saving to dependencies',
isOptional: true
},
{
options: ['-D', '--save-dev'],
init: ({ config }) => {
config.doSaveDev = false
},
action: ({ config }) => {
config.doSaveDev = true
},
msg: 'Save to devDependencies',
isOptional: true
},
{
options: ['-O', '--save-optional'],
init: ({ config }) => {
config.doSaveOptional = false
},
action: ({ config }) => {
config.doSaveOptional = true
},
msg: 'Save to optionalDependencies',
isOptional: true
},
{
options: ['-B', '--save-bundle'],
init: ({ config }) => {
config.doSaveBundle = false
},
action: ({ config }) => {
config.doSaveBundle = true
},
msg: 'Save to bundleDependencies',
isOptional: true
},
{
options: ['-E', '--save-exact'],
init: ({ config }) => {
config.doSaveExact = false
},
action: ({ config }) => {
config.doSaveExact = true
},
msg: 'Save deps with exact version',
isOptional: true
},
{
options: ['--copy'],
init: ({ config }) => {
config.doCopy = false
},
action: ({ config }) => {
config.doCopy = true
},
msg: 'Copy locally, do not link to central store',
isOptional: true
}
]
}
]
}
/**
* @summary Execute the `install` command.
*
* @param {string[]} args Command line arguments.
* @returns {number} Return code.
*
* @override
* @description
* The command has two distinct modes.
* 1. If there are no command line package names, the command is expected
* to be invoked in an xpm package folder and install the dependencies and
* devDependencies.
* 2. If there are command line package names, the command will install
* the referred packages, either globally or locally, possibly inside
* an xpm package, followed by adding the package in the dependencies.
*/
async doRun (args) {
const log = this.log
log.trace(`${this.constructor.name}.doRun()`)
const context = this.context
const config = context.config
// TODO: remove when cli-start-options is updated.
log.debug(`os arch=${os.arch()}, platform=${os.platform()},` +
` release=${os.release()}`)
log.debug(`node ${process.version}`)
log.verbose(this.title)
// const config = this.context.config
for (const arg of args) {
if (arg.startsWith('-')) {
log.warn(`'${arg}' ignored`)
}
}
context.globalConfig = new GlobalConfig()
await context.globalConfig.checkDeprecatedFolders(log)
// Do **not** default to file links on Windows, they are broken,
// DLLs are not loaded from original location.
context.hasFileSymLink = false
// The current folder may not be an xpm package or even a package at all.
this.xpack = new Xpack(config.cwd, context)
const xpack = this.xpack
try {
this.packageJson = await xpack.readPackageJson()
} catch (err) {
// This happens when not installing in an existing package,
// but in a folder.
this.packageJson = null
this.packageJsonWithInheritance = null
}
if (this.packageJson) {
this.packageJsonWithInheritance = xpack.processInheritance()
}
const packageJson = this.packageJsonWithInheritance
if (packageJson && !xpack.isXpack() &&
!config.isGlobal && !config.isSystem) {
throw new CliErrorInput(
'current folder not an xpm package, ' +
'check for the "xpack" property in package.json')
}
const minVersion = await xpack.checkMinimumXpmRequired(packageJson)
this.policies = new Policies(minVersion, context)
// Symbolic links to files do not work on Windows,
// `make` fails when starting executables via links.
context.hasFileSysLink = false
// Links to folders generally work if configured as 'junctions'.
// True symbolic links work only in Developer mode, so they
// are not very useful.
context.hasDirSysLink = false
this.issueShareNpmDependenciesWarning = false
if (args.length === 0) {
// When no package names are passed.
await this.installAllDependencies()
} else {
// When at least one package name is passed.
for (const arg of args) {
if (!arg.startsWith('-')) {
// Throws on error.
await this.installPackage(arg)
}
}
if (config.mustRewritePackageJson) {
await this.rewritePackageJson()
}
if (config.isGlobal) {
if (config.doSaveProd || config.doSaveDev || config.doSaveOptional ||
config.doSaveExact || config.doSaveBundle) {
log.warn('save related option(s) ignored for global installs')
}
} else {
if (config.doSaveBundle) {
log.warn('--save-bundle not yet implemented, ignored')
}
}
}
if (this.policies.shareNpmDependencies &&
this.issueShareNpmDependenciesWarning) {
log.warn('sharing dependencies with npm is now deprecated, ' +
'update package.json;')
log.warn('for details, see https://xpack.github.io/xpm/policies/0001/ ')
}
if (log.isVerbose()) {
this.outputDoneDuration()
}
return CliExitCodes.SUCCESS
}
// --------------------------------------------------------------------------
/**
* @summary Install one package.
*
* @param {string} pack The pack to install.
* @returns {undefined} Nothing.
*/
async installPackage (pack) {
const log = this.log
log.trace(`${this.constructor.name}.installPackage('${pack}')`)
const context = this.context
const config = context.config
const xpack = this.xpack
const cacheFolderPath = context.globalConfig.cacheFolderPath
let manifest
try {
log.trace(`pacote.manifest(${pack})`)
manifest = await pacote.manifest(pack, { cache: cacheFolderPath })
if (log.isTrace()) {
log.trace(util.inspect(manifest))
}
} catch (err) {
log.trace(util.inspect(err))
throw new CliErrorInput(`Package '${pack}' not found`)
}
const manifestIds = new ManifestIds(manifest, this.policies)
const globalPackagePath = path.join(context.globalConfig.globalFolderPath,
manifestIds.getPath())
const packFullName = manifestIds.getFullName()
if (log.isVerbose()) {
log.verbose()
log.verbose(`Processing ${packFullName}...`)
} else {
log.info(`${packFullName}...`)
}
if (config.isSystem) {
// System install.
await this.installPackageInSystem(
{ pack, cacheFolderPath, manifestIds })
} else if (config.isGlobal) {
// Global install.
await this.installPackageGlobally(
{ pack, globalPackagePath, cacheFolderPath, manifestIds })
} else if (xpack.isXpack()) {
// In xPack install.
await this.installPackageInXpack(
{ pack, globalPackagePath, cacheFolderPath, manifest, manifestIds })
} else {
// Standalone install.
await this.installPackageStandalone(
{ pack, globalPackagePath, cacheFolderPath, manifest, manifestIds })
}
}
async installPackageInSystem ({
pack,
cacheFolderPath,
manifestIds
}) {
assert(pack)
assert(cacheFolderPath)
assert(manifestIds)
const context = this.context
const config = context.config
const log = this.log
log.trace(`${this.constructor.name}.installPackageInSystem('${pack}')`)
if (config.configurationName) {
throw new CliErrorInput('--config incompatible with --system')
}
// TODO: implement it.
throw new CliError('system install is not yet implemented')
}
async installPackageGlobally ({
pack,
globalPackagePath,
cacheFolderPath,
manifestIds
}) {
assert(pack)
assert(globalPackagePath)
assert(cacheFolderPath)
assert(manifestIds)
const context = this.context
const config = context.config
const log = this.log
log.trace(`${this.constructor.name}.installPackageGlobally('${pack}')`)
if (config.configurationName) {
throw new CliErrorInput('--config incompatible with --global')
}
if (config.isDryRun) {
log.verbose(`Pretend installing globally in '${globalPackagePath}'...`)
return
}
if (config.doCopy) {
log.warn('global installs always copy content to the central ' +
'store, no need for --copy')
}
// Global install.
await this.pacoteExtractPackage({
packFullName: manifestIds.getFullName(),
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: globalPackagePath,
cacheFolderPath,
setReadOnly: true,
verboseMessage:
`Installing globally in '${globalPackagePath}'...`
})
}
/**
* @summary Install inside an existing xpm package.
* @param {*} params Parameters
* @returns {undefined} Nothing.
*
* May return CliExitCodes.ERROR.OUTPUT if already installed.
*/
async installPackageInXpack ({
pack,
globalPackagePath,
cacheFolderPath,
manifest,
manifestIds
}) {
assert(pack)
assert(globalPackagePath)
assert(cacheFolderPath)
assert(manifest)
assert(manifestIds)
const log = this.log
log.trace(`${this.constructor.name}.installPackageInXpack('${pack}')`)
log.trace(`globalPackagePath: ${globalPackagePath}`)
const context = this.context
const config = context.config
const xpack = this.xpack
const packageJson = this.packageJsonWithInheritance
const packFullName = manifestIds.getFullName()
log.trace(`${packFullName}`)
let globalJson = await xpack.isFolderPackage(globalPackagePath)
if (!globalJson || config.doForce) {
if (config.isDryRun) {
log.verbose(`Pretend adding to central store '${globalPackagePath}'...`)
return
}
// The package is not present in the central store, add it there.
await this.pacoteExtractPackage({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: globalPackagePath,
cacheFolderPath,
setReadOnly: true,
verboseMessage:
`Adding to central store '${globalPackagePath}'...`
})
// Parse again the newly installed package.
globalJson = await xpack.isFolderPackage(globalPackagePath)
}
if (config.isDryRun) {
log.info('Dry run...')
return
}
let buildFolderRelativePath
if (config.configurationName) {
const configuration = xpack.retrieveConfiguration({
packageJson,
configurationName: config.configurationName
})
const liquidEngine = new XpmLiquid(log)
let liquidMap
try {
liquidMap = liquidEngine.prepareMap(packageJson,
config.configurationName)
} catch (err) {
log.trace(util.inspect(err))
throw new CliError(err.message)
}
buildFolderRelativePath = await xpack.computeBuildFolderRelativePath({
liquidEngine,
liquidMap,
configuration,
configurationName: config.configurationName
})
}
// Install in the local package or configuration folder.
const xpacksPath = path.join(config.cwd,
buildFolderRelativePath || '',
context.globalConfig.localXpacksFolderName)
const localPackagePath = path.join(xpacksPath,
manifestIds.getFolderName())
log.debug(`local path: ${localPackagePath}`)
const destJson = await xpack.isFolderPackage(localPackagePath)
if (destJson) {
// Destination looks like an existing package, be careful.
if (config.doForce) {
log.verbose(`Removing existing package from '${localPackagePath}'...`)
await deleteAsync(localPackagePath, { force: true })
} else {
log.warn(`package ${packFullName} already installed; ` +
'use --force to overwrite')
// TODO: decide if there should be an error or success.
return // CliExitCodes.ERROR.OUTPUT
}
} else {
// Destination is not an xpm package, may be a custom folder,
// or even a file.
try {
const stat = await fsPromises.stat(localPackagePath)
const kind = stat.isDirectory ? 'folder' : 'file'
if (config.doForce) {
log.verbose(`Removing existing ${kind} '${localPackagePath}'...`)
await deleteAsync(localPackagePath, { force: true })
} else {
log.warn(`${kind} '${packFullName}' already installed; ` +
'use --force to overwrite')
// TODO: decide if there should be an error or success.
return // CliExitCodes.ERROR.OUTPUT
}
} catch (err) {
await deleteAsync(localPackagePath, { force: true })
}
}
if (config.doCopy) {
await this.pacoteExtractPackage({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: localPackagePath,
cacheFolderPath,
setReadOnly: false,
verboseMessage:
// TODO: make relative to project?!
`Copying to local folder '${localPackagePath}'...`
})
} else {
await this.addFolderLinkToGlobalRepo({
globalJson,
manifestIds,
globalPackagePath,
buildFolderRelativePath
})
}
// Process binaries and dependencies.
let destinationDependencies
if (xpack.isXpack(globalJson)) {
// xPack
// const xpacksPath = path.join(config.cwd,
// context.globalConfig.localXpacksFolderName)
if (xpack.isBinaryXpack(globalJson)) {
// Add links to executables listed in xpack/bin
await this.addDotBinLinks({
// Since Nov. 2024, `executables` is preferred to `bin`.
executables: globalJson.xpack.executables ?? globalJson.xpack.bin,
fromFolderPath: globalPackagePath,
localFolderName: manifestIds.getFolderName(),
globalFolderRelativePath: manifestIds.getPath(),
destFolderPath: xpacksPath,
buildFolderRelativePath
})
// By default, binary xpm packages go to devDependencies.
destinationDependencies = 'devDependencies'
} else {
// By default, source xpm packages go to dependencies.
destinationDependencies = 'dependencies'
}
} else {
// If not xpm package, it must be a node module.
if (!this.policies.shareNpmDependencies) {
throw new CliError(`${pack} is not an xpm package, ` +
'use npm to install it')
}
// The shareNpmDependencies case.
if (buildFolderRelativePath) {
throw new CliError('npm dependencies not supported in --config')
}
const nodeModulesPath = path.join(config.cwd,
context.globalConfig.localNpmFolderName)
if (xpack.isBinaryNodeModule(globalJson)) {
await this.addDotBinLinks({
executables: globalJson.bin,
fromFolderPath: globalPackagePath,
localFolderName: manifestIds.getFolderName(),
globalFolderRelativePath: manifestIds.getPath(),
destFolderPath: nodeModulesPath,
buildFolderRelativePath
})
}
// By default, all npm packages go to `devDependencies`, to avoid
// `xpm install` pulling all their dependencies.
destinationDependencies = 'devDependencies'
}
this.addDependencyToPackageJson({
manifest,
manifestIds,
defaultDestination: destinationDependencies,
configurationName: config.configurationName
})
}
/**
* @summary Install inside a standalone folder.
* @param {*} params Parameters
* @returns {undefined} Nothing.
*
* May return CliExitCodes.ERROR.OUTPUT if already installed.
*/
async installPackageStandalone ({
pack,
globalPackagePath,
cacheFolderPath,
manifest,
manifestIds
}) {
assert(pack)
assert(globalPackagePath)
assert(cacheFolderPath)
assert(manifest)
assert(manifestIds)
const log = this.log
log.trace(`${this.constructor.name}.installPackageStandalone('${pack}')`)
const context = this.context
const config = context.config
const xpack = this.xpack
if (config.configurationName) {
throw new CliErrorInput('--config incompatible with standalone installs')
}
const packFullName = manifestIds.getFullName()
log.trace(`${packFullName}`)
let globalJson = await xpack.isFolderPackage(globalPackagePath)
if (!globalJson || config.doForce) {
if (config.isDryRun) {
log.verbose(`Pretend adding to central store '${globalPackagePath}'...`)
return
}
// The package is not present in the central store, add it there.
await this.pacoteExtractPackage({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: globalPackagePath,
cacheFolderPath,
setReadOnly: true,
verboseMessage:
`Adding to central store '${globalPackagePath}'...`
})
globalJson = await xpack.isFolderPackage(globalPackagePath)
}
if (config.isDryRun) {
log.info('Dry run...')
return
}
if (xpack.isBinaryXpack(globalJson)) {
throw new CliError(
'binary xpm package installed globally, next time use --global')
}
// Install in the current folder
const localPackagePath = path.join(config.cwd, manifestIds.getFolderName())
log.debug(`local path ${localPackagePath}`)
try {
// Avoid overriding an existing folder.
await fsPromises.stat(localPackagePath)
if (config.doForce) {
log.verbose(`Removing existing package from '${localPackagePath}'...`)
await deleteAsync(localPackagePath, { force: true })
} else {
throw CliError(`package ${packFullName} already installed, ` +
'use --force to overwrite',
CliExitCodes.ERROR.OUTPUT)
}
} catch (err) {
// Not present, no danger.
}
const localPackageTmpPath = localPackagePath + '.tmp'
log.trace(`del(${localPackageTmpPath})`)
await deleteAsync(localPackageTmpPath, { force: true })
// Extract a local copy here too.
await this.pacoteExtract({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: localPackagePath,
destinationTmpFolderPath: localPackageTmpPath,
cacheFolderPath,
verboseMessage:
`Installing standalone package in '${localPackagePath}'...`
})
// When everything is ready, rename the folder to the desired name.
await fsPromises.rename(localPackageTmpPath, localPackagePath)
log.trace(`rename(${localPackageTmpPath}, ${localPackagePath})`)
// Standalone packages preserve their mode bits, are not set to RO.
}
// --------------------------------------------------------------------------
// Check if the installed packages must be added to the dependencies and
// return the dependencies group or null.
computeDependencyDestination (defaultDestination) {
const context = this.context
const config = context.config
if (config.doSaveOptional) {
return 'optionalDependencies'
}
if (config.doSaveDev) {
return 'devDependencies'
}
if (config.doSaveProd) {
return 'dependencies'
}
if (!config.doNotSave) {
return defaultDestination || 'dependencies'
}
return null
}
computeDependencyValue ({
manifest
}) {
assert(manifest)
const context = this.context
const config = context.config
if (manifest._from.match(/^git[+][a-zA-Z]+:/) ||
manifest._from.match(/^[a-zA-Z]+:/)) {
// If an URL, keep it as is.
return manifest._from
}
// Checking '-' identifies binaries which use pre-release;
// they all should be referred with exact versions.
return (config.doSaveExact || manifest.version.includes('-'))
? manifest.version
: `^${manifest.version}`
}
// Add dependencies to xpack.dependencies or xpack.devDependencies
// For older projects, add to npm dependencies.
addDependencyToPackageJson ({
manifest,
manifestIds,
defaultDestination,
configurationName
}) {
assert(manifest)
assert(manifestIds)
const log = this.log
log.trace(`${this.constructor.name}.addDependency('${manifest._from}')`)
const context = this.context
const config = context.config
const packageJson = this.packageJson
if (packageJson) {
// Add to `dependencies` or `devDependencies`.
const dependencyDestination =
this.computeDependencyDestination(defaultDestination)
if (dependencyDestination) {
const depName = manifestIds.getScopedName()
const depValueString = this.computeDependencyValue({ manifest })
let depValue
if (this.policies.onlyStringDependencies) {
depValue = depValueString
if (config.doCopy) {
log.warn('package.json minimumXpmRequired does not support ' +
`copied '${depName}' dependency`)
}
} else {
depValue = {
specifier: depValueString,
local: config.doCopy ? 'copy' : 'link',
platforms: 'all'
}
}
// Decide where to add the new dependency, configuration vs project.
let target
if (configurationName) {
// Prefer `buildConfigurations`, but also accept `configurations`.
if (packageJson.xpack.buildConfigurations) {
target = packageJson.xpack.buildConfigurations[configurationName]
} else if (packageJson.xpack.configurations) {
// TODO: Legacy, remove it at some point.
target = packageJson.xpack.configurations[configurationName]
} else {
assert(packageJson.xpack.buildConfigurations[configurationName])
}
} else {
if (this.policies.shareNpmDependencies) {
target = packageJson
} else {
// Starting with 0.14.x, dependencies are below xpack.
target = packageJson.xpack
}
}
['dependencies', 'devDependencies', 'optionalDependencies'].forEach(
(dependency) => {
if (Object.prototype.hasOwnProperty.call(
target, dependency)) {
if (Object.prototype.hasOwnProperty.call(
target[dependency], depName)) {
if (configurationName) {
log.verbose(`Removing '${depName}' from ` +
`'${configurationName}/${dependency}'`)
} else {
log.verbose(`Removing '${depName}' from ` +
`'${dependency}'`)
}
delete target[dependency][depName]
}
}
})
if (configurationName) {
log.verbose(`Adding '${manifestIds.getScopedName()}' to ` +
`'${configurationName}/${dependencyDestination}'...`)
} else {
log.verbose(`Adding '${manifestIds.getScopedName()}' to ` +
`'${dependencyDestination}'...`)
}
// If the destination object is not yet there, create an empty one.
if (!Object.prototype.hasOwnProperty.call(
target, dependencyDestination)) {
target[dependencyDestination] = {}
}
// Store dependency value, possibly overriding old one.
target[dependencyDestination][depName] = depValue
log.trace(`depValue ${depValue}`)
// Mark the json dirty, to be written when the command terminates.
config.mustRewritePackageJson = true
}
}
}
async rewritePackageJson () {
const log = this.log
log.trace(`${this.constructor.name}.rewritePackageJson()`)
const context = this.context
const config = context.config
const xpack = this.xpack
const packageJson = this.packageJson
if (!packageJson || !config.mustRewritePackageJson) {
return
}
await xpack.rewritePackageJson()
}
// --------------------------------------------------------------------------
/**
* @summary Install all dependencies.
*
* @details
* Add links into `<project>/xpacks` and `build/<config>/xpacks.
*
* If `--config` is used, add links only to the configuration build folder.
*
* @returns {undefined} Nothing.
*/
async installAllDependencies () {
const log = this.log
const context = this.context
const config = context.config
const xpack = this.xpack
const packageJson = this.packageJsonWithInheritance
log.trace(`${this.constructor.name}.installAllDependencies()`)
if (!packageJson) {
throw new CliErrorInput(
'current folder not a valid package, check for package.json')
}
if (config.isGlobal) {
throw new CliErrorInput(
'--global supported only when explicitly installing packages')
}
if (config.isSystem) {
throw new CliErrorInput(
'--system supported only when explicitly installing packages')
}
if (config.doSaveProd || config.doSaveDev || config.doSaveOptional ||
config.doSaveBundle || config.doSaveExact) {
throw new CliErrorInput(
'--save-* supported only when explicitly installing packages')
}
config.doSkipIfInstalled = true
if (config.configurationName) {
// Process only this configuration
if (config.isAllConfigs) {
log.warn('option --all-configs ignored')
}
const configuration = xpack.retrieveConfiguration({
packageJson,
configurationName: config.configurationName
})
const { collectedDependenciesMap, buildFolderRelativePath } =
await this.collectConfigurationDependencies({
configurationName: config.configurationName,
configuration
})
await this.downloadAndProcessDependencies({
collectedDependenciesMap,
buildFolderRelativePath
})
} else {
if (config.isDryRun) {
log.verbose('Pretend installing npm dependencies...')
} else {
if (this.policies.shareNpmDependencies &&
(packageJson.dependencies || packageJson.devDependencies)) {
log.verbose()
log.verbose('Installing npm dependencies...')
const spawn = new Spawn()
// https://docs.npmjs.com/cli/v8/commands/npm-install
// With `--production`, npm will not install modules listed
// in devDependencies.
let cmd = 'npm install --color=false' +
' --no-audit --no-fund --no-save'
if (log.isVerbose()) {
log.verbose(`> ${cmd}`)
} else {
cmd += ' --quiet'
log.trace(`> ${cmd}`)
}
// const code = await spawn.executeShellPromise(
let result
try {
result = await spawn.spawnShellPromise(
cmd,
{
cwd: config.cwd,
log
})
} catch (err) {
log.verbose(err)
throw new CliError(
'install dependencies failed (npm returned error)')
}
const code = result.code
if (code !== 0) {
throw new CliError(
`install dependencies failed (npm returned ${code})`)
}
}
}
// Process the top package and all configurations
const { collectedDependenciesMap } =
await this.collectPackageDependencies()
await this.downloadAndProcessDependencies({
collectedDependenciesMap
})
if (config.isAllConfigs) {
const enumerateConfigurations = async (from) => {
for (const [configurationName, configuration] of
Object.entries(from)) {
if (configuration.hidden) {
// Ignore hidden configurations.
} else {
if ((configuration.dependencies &&
Object.keys(configuration.dependencies).length) ||
(configuration.devDependencies &&
Object.keys(configuration.devDependencies).length) ||
log.isVerbose()) {
if (!log.isVerbose()) {
log.info()
}
const { collectedDependenciesMap, buildFolderRelativePath } =
await this.collectConfigurationDependencies({
configurationName,
configuration
})
await this.downloadAndProcessDependencies({
collectedDependenciesMap,
buildFolderRelativePath
})
}
}
}
}
if (packageJson.xpack.buildConfigurations) {
await enumerateConfigurations(packageJson.xpack.buildConfigurations)
}
// TODO: Legacy, remove it at some point.
if (packageJson.xpack.configurations) {
await enumerateConfigurations(packageJson.xpack.configurations)
}
}
}
}
async collectPackageDependencies () {
const log = this.log
const packageJson = this.packageJsonWithInheritance
if (log.isVerbose()) {
log.verbose()
log.verbose(
`Collecting dependencies for package ${packageJson.name}...`)
} else {
log.info(`${packageJson.name}...`)
}
const collectedDependenciesMap = {}
const from = this.policies.shareNpmDependencies
? packageJson
: packageJson.xpack
await this.collectDependencies({
json: packageJson,
dependencies: from.dependencies,
outputMap: collectedDependenciesMap
})
log.trace('Collecting devDependencies...')
await this.collectDependencies({
json: packageJson,
dependencies: from.devDependencies,
isDev: true,
outputMap: collectedDependenciesMap
})
return {
collectedDependenciesMap
}
}
async collectConfigurationDependencies ({
configurationName,
configuration
}) {
assert(configurationName)
assert(configuration)
const log = this.log
const xpack = this.xpack
const packageJson = this.packageJsonWithInheritance
const collectedDependenciesMap = {}
const liquidEngine = new XpmLiquid(log)
let liquidMap
try {
liquidMap = liquidEngine.prepareMap(packageJson,
configurationName)
} catch (err) {
log.trace(util.inspect(err))
throw new CliError(err.message)
}
const buildFolderRelativePath =
await xpack.computeBuildFolderRelativePath({
liquidEngine,
liquidMap,
configuration,
configurationName
})
if (log.isVerbose()) {
log.verbose()
log.verbose(
`Collecting dependencies for package ${packageJson.name}, ` +
`configuration ${configurationName}...`)
} else {
log.info(`${packageJson.name} --config ${configurationName}...`)
}
await this.collectDependencies({
json: packageJson,
configurationName,
dependencies: configuration.dependencies,
outputMap: collectedDependenciesMap
})
log.trace('Collecting devDependencies...')
await this.collectDependencies({
json: packageJson,
configurationName,
dependencies: configuration.devDependencies,
isDev: true,
outputMap: collectedDependenciesMap
})
return {
collectedDependenciesMap,
buildFolderRelativePath
}
}
async downloadAndProcessDependencies ({
collectedDependenciesMap,
buildFolderRelativePath
}) {
const log = this.log
const manifestsArray = Object.values(collectedDependenciesMap)
if (manifestsArray.length) {
log.verbose()
log.verbose(`Installing ${manifestsArray.length} dependencies...`)
const installDependencyPromisesArray = []
for (const manifest of manifestsArray) {
installDependencyPromisesArray.push(
this.downloadAndProcessOneDependency({
manifest,
buildFolderRelativePath
})
)
}
const responses = await Promise.all(installDependencyPromisesArray)
responses.forEach((value) => {
log.trace(value)
})
} else {
log.verbose('None')
}
}
// Will be executed in parallel via `Promise.all()`.
// Returns a debug code.
async downloadAndProcessOneDependency ({
manifest,
buildFolderRelativePath
}) {
const log = this.log
log.trace(
`${this.constructor.name}.downloadAndLinkOneDependency(${manifest.name})`)
const context = this.context
const config = context.config
const xpack = this.xpack
const cacheFolderPath = context.globalConfig.cacheFolderPath
if (log.isTrace()) {
log.trace(util.inspect(manifest))
}
const manifestIds = new ManifestIds(manifest, this.policies)
const globalPackagePath = path.join(
context.globalConfig.globalFolderPath,
manifestIds.getPath())
const packFullName = manifestIds.getFullName()
log.trace(`${packFullName}`)
let verboseMessage
// log.trace(`${globalPackagePath}`)
let globalJson = await xpack.isFolderPackage(globalPackagePath)
if (!globalJson) {
// The package does not exist in the central storage.
if (config.isDryRun) {
verboseMessage = `Pretend adding '${packFullName}' to ` +
`central store as '${globalPackagePath}'...`
} else {
verboseMessage = `Adding '${packFullName}' to ` +
`central store as '${globalPackagePath}'...`
}
await this.pacoteExtractPackage({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: globalPackagePath,
cacheFolderPath,
setReadOnly: true,
verboseMessage
})
globalJson = await xpack.isFolderPackage(globalPackagePath)
}
if (!globalJson.xpack) {
if (log.isVerbose()) {
log.verbose(`Package '${packFullName}'` +
' already installed by npm')
} else {
log.trace(`'${packFullName}' not an xpm package,` +
' already installed by npm')
}
return 1 // npm packages end here.
}
// log.trace(util.inspect(globalJson))
// Link to package or configuration folder.
if (config.isDryRun) {
const folderPath = path.join(
context.globalConfig.localXpacksFolderName,
manifestIds.getFolderName())
if (log.isVerbose()) {
log.verbose(`Pretend folder '${folderPath}' is ` +
`linked to global '${manifestIds.getPath()}'...`)
} else {
log.info(`'${folderPath}' ` +
`-> '${globalPackagePath}' (dry run)`)
}
return 2 // Dry runs end here.
}
this.issueShareNpmDependenciesWarning = true
// Add links to the central storage and for binaries.
const xpacksPath = path.join(config.cwd,
buildFolderRelativePath || '',
context.globalConfig.localXpacksFolderName)
const localPackagePath = path.join(xpacksPath,
manifestIds.getFolderName())
// For now install only devDependencies.
// TODO: decide what happens to dependencies.
if (globalJson.xpack || manifest.isContributedByDevDependencies) {
if (manifest.xpmDependencyKind === 'copy') {
log.trace(`deleteAsync(${localPackagePath})`)
await deleteAsync(localPackagePath, { force: true })
await this.pacoteExtractPackage({
packFullName,
manifestFrom: manifestIds.getPacoteFrom(),
destinationFolderPath: localPackagePath,
cacheFolderPath,
setReadOnly: false,
verboseMessage:
// TODO: make relative to project?!
`Copying to local folder '${localPackagePath}'...`
})
} else {
await this.addFolderLinkToGlobalRepo({
globalJson,
manifestIds,
globalPackagePath,
buildFolderRelativePath
})
}
try {
// xPack
if (xpack.isBinaryXpack(globalJson)) {
await this.addDotBinLinks({
// Since Nov. 2024, `executables` is preferred to `bin`.
executables: globalJson.xpack.executables ?? globalJson.xpack.bin,
fromFolderPath: globalPackagePath,
localFolderName: manifestIds.getFolderName(),
globalFolderRelativePath: manifestIds.getPath(),
destFolderPath: xpacksPath,
buildFolderRelativePath
})
}
} catch (err) {
if (err.code !== 'EEXIST') {
log.trace(util.inspect(err))
throw new CliError(err)
}
}
}
return 3 // Normal
}
async addDotBinLinks ({
executables,
fromFolderPath,
localFolderName,
globalFolderRelativePath,
destFolderPath,
buildFolderRelativePath
}) {
assert(fromFolderPath)
assert(localFolderName)
assert(globalFolderRelativePath)
assert(destFolderPath)
const log = this.log
log.trace(`${this.constructor.name}.addBinLinks(` +
`fromFolderPath: ${fromFolderPath}, ` +
`destFolderPath: ${destFolderPath}, ` +
`localFolderName: ${localFolderName}, ` +
`globalFolderRelativePath: ${globalFolderRelativePath}, ` +
`buildFolderRelativePath: ${buildFolderRelativePath})`)
const context = this.context
// const config = context.config
// Either xpacks or node_modules
const localGroupFolderName = path.basename(destFolderPath)
const dotBin = context.globalConfig.dotBin
const binFolderPath = path.join(destFolderPath, dotBin)
// Create the .bin folder
await makeDirectory(binFolderPath)
for (const [key, value] of Object.entries(executables)) {
let valueFileRelativePath
let isCopy = false
if (typeof value === 'string' || value instanceof String) {
valueFileRelativePath = value
} else if (typeof value.path === 'string' ||
value.path instanceof String) {
valueFileRelativePath = value.path
if (value.type === 'copy') {
isCopy = true
}
}
let fromFilePath
fromFilePath = path.join(fromFolderPath, valueFileRelativePath)
const toFilePath = path.join(binFolderPath, key)
let suffix = ''
try {
// If the original file is not present, throw.
log.trace(`stat ${fromFilePath}`)
await fsPromises.stat(fromFilePath)
} catch (err) {
if (os.platform() === 'win32') {
// As usual, things are a bit more complicated on Windows,
// and it is necessary to process the explicit `.exe`.
// #206 - it ignores names like ld.gold.
// const parts = path.parse(fromFilePath)
// if (parts.ext) {
// // If the path has an explicit extension, and it was not found,
// // the file is definitely not there.
// continue
// }
// Check again the original, but this time with the Windows extension.
fromFilePath += '.exe'
suffix = '.exe'
try {
log.trace(`stat ${fromFilePath}`)
await fsPromises.stat(fromFilePath)
} catch (err) {
// Neither the POSIX name, nor the Windows name is present.
continue
}
} else {
// The original file does not exist, nothing to link.
continue
}
}
const globalRelativeFilePath = path.join(globalFolderRelativePath,
valueFileRelativePath)
const localRelativeFilePath = path.join(localGroupFolderName, dotBin, key)
if (!isCopy) {
let fromRelativePath
if (os.platform() === 'win32') {
// On Windows the shims use paths relative to the project
// or build folder.
fromRelativePath = path.join(
context.globalConfig.localXpacksFolderName,
localFolderName,
valueFileRelativePath) + suffix
} else {
// On Unix symbolic links are relative to the .bin folder.
fromRelativePath = path.join(
'..',
localFolderName,
valueFileRelativePath)
}
await this.addLinkToExecutable({
fromFilePath,
toFilePath,
suffix,
localRelativeFilePath,
globalRelativeFilePath,
fromRelativePath,
buildFolderRelativePath
})
} else {
// Currently not used.
// Delete any existing link or file/folder.
await deleteAsync(toFilePath, { force: true })
await copyFile(fromFilePath, toFilePath, { overwrite: true })
if (log.isVerbose()) {
log.verbose(
`File '${localRelativeFilePath}' ` +
`copied from '${globalRelativeFilePath}'`)
} else {
log.info(
`'${globalRelativeFilePath}' => '${localRelativeFilePath}'`)
}
}
}
}
async addLinkToExecutable ({
fromFilePath,
toFilePath,
suffix,
localRelativeFilePath,
globalRelativeFilePath,
fromRelativePath,
buildFolderRelativePath
}) {
assert(fromFilePath)
assert(toFilePath)
assert(localRelativeFilePath)
assert(globalRelativeFilePath)
assert(fromRelativePath)
const log = this.log
log.trace(`${this.constructor.name}.addLinkToExecutable(` +
`fromFilePath: ${fromFilePath}, ` +
`fromRelativePath: ${fromRelativePath}, ` +
`toFilePath: ${toFilePath}, ` +
`localRelativeFilePath: ${localRelativeFilePath}, ` +
`globalRelativeFilePath: ${globalRelativeFilePath}, ` +
`buildFolderRelativePath: ${buildFolderRelativePath})`)
const context = this.context
if (os.platform() === 'win32') {
// Remove all possible links or shims.
await deleteAsync(`${toFilePath}${suffix}`, { force: true })
await deleteAsync(`${toFilePath}.cmd`, { force: true })
await deleteAsync(`${toFilePath}.ps1`, { force: true })
await deleteAsync(toFilePath, { force: true })
if (context.hasFileSymLink) {
// The first choice, but works only if Developer Mode is enabled.
log.trace(`symlink('${fromFilePath}', '${toFilePath}${suffix}')`)
try {
await fsPromises.symlink(fromFilePath,
`${toFilePath}${suffix}`, 'file')
if (log.isVerbose()) {
log.verbose(
`File '${localRelativeFilePath}${suffix}' ` +
`linked to global '${globalRelativeFilePath}${suffix}'`)
} else {
log.info(
`'${localRelativeFilePath}${suffix}' -> '${fromFilePath}'`)
}
} catch (err) {
log.warn('Developer Mode not enabled, using .cmd shims.')
context.hasFileSymLink = false
}
}
if (!context.hasFileSymLink) {
if (useAbsolutePathsWindows) {