UNPKG

xpm

Version:

The xPack project manager command line tool

724 lines (635 loc) 21.6 kB
/* * This file is part of the xPack project (http://xpack.github.io). * Copyright (c) 2019-2026 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' // ---------------------------------------------------------------------------- /** * The `xpm install <options> ...` command implementation. */ // ---------------------------------------------------------------------------- // https://nodejs.org/docs/latest/api/ import assert from 'assert' import fs from 'fs/promises' // import util from 'util' import os from 'os' import path from 'path' // ---------------------------------------------------------------------------- // https://www.npmjs.com/package/del import { deleteAsync } from 'del' // ---------------------------------------------------------------------------- // ES6: `import { CliCommand, CliExitCodes, CliError } from 'cli-start-options' // import { CliCommand, CliExitCodes, CliErrorSyntax, // CliError, CliErrorInput } from '@ilg/cli-start-options' import cliStartOptionsCsj from '@ilg/cli-start-options' // https://www.npmjs.com/package/@xpack/xpm-lib import * as xpmLib from '@xpack/xpm-lib' import { convertXpmError } from '../functions/convert-xpm-errors.js' // ---------------------------------------------------------------------------- import { GlobalConfig } from '../classes/global-config.js' // ---------------------------------------------------------------------------- const { CliCommand, CliExitCodes, CliErrorSyntax, CliError, CliErrorInput } = cliStartOptionsCsj // ============================================================================ /** * @typedef {Object} Uninstall * @property {GlobalConfig} globalConfig Global configuration properties. * @property {Xpack} xpack The object with xPack utilities. * @property {Object} jsonPackage The object parsed by xpack; may be null. */ export class Uninstall 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 - uninstall package(s)' this.optionGroups = [ { title: 'Uninstall options', postOptions: '[[@<scope>/]<name>[@<version]...', optionDefs: [ { options: ['-g', '--global'], init: ({ config }) => { config.isGlobal = false }, action: ({ config }) => { config.isGlobal = true }, msg: 'Uninstall the global package(s)', isOptional: true, }, // { // options: ['-sy', '--system'], // init: ({ config }) => { // config.isSystem = false // }, // action: ({ config }) => { // config.isSystem = true // }, // msg: 'Uninstall the system package(s) (not impl)', // isOptional: true // }, { options: ['-c', '--config'], init: ({ config }) => { config.configurationName = undefined }, action: ({ config }, val) => { config.configurationName = val.trim() }, msg: 'Show the configuration specific dependencies', param: 'config_name', isOptional: true, }, { options: ['-n', '--dry-run'], init: ({ config }) => { config.isDryRun = false }, action: ({ config }) => { config.isDryRun = true }, msg: 'Pretend to uninstall the package', isOptional: true, }, { options: ['--no-save'], init: ({ config }) => { config.doNotSave = false }, action: ({ config }) => { config.doNotSave = true }, msg: 'Prevent saving to dependencies', isOptional: true, }, { options: ['--ignore-errors'], init: ({ config }) => { config.isIgnoreErrors = false }, action: ({ config }) => { config.isIgnoreErrors = true }, msg: 'Ignore script errors', isOptional: true, }, ], }, ] } /** * @summary Execute the `uninstall` command. * * @param {string[]} args Command line arguments. * @returns {Number|Promise} The process exit code. * * @override * @description * TODO */ async doRun(args) { const log = this.log const context = this.context const config = context.config log.trace(`${this.constructor.name}.doRun()`) log.verbose(this.title) log.verbose() // Extra options are not caught by CLI and must be checked/filtered here. args.forEach((element) => { if (element.startsWith('-')) { log.warn(`'${element}' ignored`) } }) args = args.filter((element) => { return !element.startsWith('-') }) if (args.length === 0) { throw new CliErrorSyntax('this command requires arguments') } context.globalConfig = new GlobalConfig() await context.globalConfig.checkDeprecatedFolders(log) // The current folder may not be an xpm package or even a package at all. const xpmPackage = new xpmLib.Package({ log, packageFolderPath: config.cwd, }) this.xpmPackage = xpmPackage this.jsonPackage = await xpmPackage.readPackageDotJson() try { const minVersion = await xpmPackage.checkMinimumXpmRequired({ xpmRootFolderPath: context.rootPath, }) this.policies = new xpmLib.Policies({ log, minVersion }) } catch (error) { throw convertXpmError(error) } for (const arg of args) { if (config.isSystem) { await this.uninstallPackageSystem(arg) } else if (config.isGlobal) { await this.uninstallOnePackageGlobally(arg) } else { await this.uninstallOnePackageLocally(arg) } } if (this.jsonPackage && config.mustRewritePackageJson) { await xpmPackage.rewritePackageDotJson(this.jsonPackage) } if (log.isVerbose) { this.outputDoneDuration() } return CliExitCodes.SUCCESS } // -------------------------------------------------------------------------- /** * @summary Uninstall a single package from the local folder. * * @param {String} npmPackageSpecifier Packages specifier, * as [@scope/]name[@version]. * @returns {undefined|Promise} Nothing. * * @description * Remove the package link from the local * folder, which must be an xpm package. The version is ignored. */ async uninstallOnePackageLocally(npmPackageSpecifier) { const log = this.log log.trace( `${this.constructor.name}.` + `uninstallOnePackageLocally('${npmPackageSpecifier}')` ) const context = this.context const config = context.config const configurationName = config.configurationName const xpmPackage = this.xpmPackage if (!xpmPackage.isNpmPackage()) { throw new CliErrorInput( 'current folder is not a valid package, check for package.json' ) } if (!xpmPackage.isXpmPackage()) { throw new CliErrorInput( 'current folder is not an xpm package, ' + 'check for the "xpack" property in package.json' ) } const { scope, name } = xpmPackage.parsePackageSpecifier({ npmPackageSpecifier, }) if (!name) { throw new CliErrorInput( `'${npmPackageSpecifier}' has no valid package name` ) } const dependencyLocation = this.policies.nonHierarchicalLocalXpacksFolder ? (scope ? scope.slice(1) + '-' : '') + name : scope ? scope + '/' + name : name let dependencyShownLocation = context.globalConfig.localXpacksFolderName + '/' + dependencyLocation const dotBin = context.globalConfig.dotBin let xPackFolderPath let xPacksBasePath = config.cwd if (configurationName) { const xpmDataModel = new xpmLib.DataModel({ log, jsonPackage: this.jsonPackage, }) this.xpmDataModel = xpmDataModel const buildConfigurations = xpmDataModel.buildConfigurations await buildConfigurations.initialise() if (!buildConfigurations.has(configurationName)) { throw new CliErrorInput( `missing "xpack.buildConfigurations.${configurationName}" ` + 'property in package.json' ) } const buildConfiguration = buildConfigurations.get(configurationName) this.buildConfiguration = buildConfiguration await buildConfiguration.initialise() const buildFolderRelativePath = buildConfiguration.buildFolderRelativePath xPacksBasePath = path.join(config.cwd, buildFolderRelativePath) if (this.policies.nonHierarchicalLocalXpacksFolder) { xPackFolderPath = path.join( config.cwd, buildFolderRelativePath, context.globalConfig.localXpacksFolderName, dependencyLocation ) } else { xPackFolderPath = path.join( config.cwd, buildFolderRelativePath, context.globalConfig.localXpacksFolderName, scope, name ) } dependencyShownLocation = buildFolderRelativePath + '/' + context.globalConfig.localXpacksFolderName + '/' + dependencyLocation } else { if (this.policies.nonHierarchicalLocalXpacksFolder) { xPackFolderPath = path.join( config.cwd, context.globalConfig.localXpacksFolderName, dependencyLocation ) } else { xPackFolderPath = path.join( config.cwd, context.globalConfig.localXpacksFolderName, scope, name ) } dependencyShownLocation = context.globalConfig.localXpacksFolderName + '/' + dependencyLocation } log.trace(`dependencyShownLocation: ${dependencyShownLocation}`) log.trace(`xPackFolderPath: ${xPackFolderPath}`) let stat try { stat = await fs.lstat(xPackFolderPath) } catch { log.warn(`${npmPackageSpecifier} not a dependency, ignored`) stat = undefined } if (stat) { if (stat.isDirectory()) { if (!config.isDryRun) { // Remove the corresponding bin links. await this.removeDotBinLinks({ xPacksBasePath, dotBinRelativePath: path.join( context.globalConfig.localXpacksFolderName, dotBin ), packagePath: xPackFolderPath, }) // Remove the folder, it should not throw, it was verified. log.trace(`deleteAsync(${xPackFolderPath})`) await deleteAsync(xPackFolderPath, { force: true }) if (log.isVerbose) { log.verbose(`Folder '${dependencyShownLocation}' removed`) } else { log.info(`'${dependencyShownLocation}' removed`) } const key = (scope ? `${scope}/` : '') + `${name}` this.updateDependencies(key) } else { log.info(`Folder '${dependencyShownLocation}'` + ' should be removed') } } else if (stat.isSymbolicLink()) { if (!config.isDryRun) { // Remove the corresponding bin links. await this.removeDotBinLinks({ xPacksBasePath, dotBinRelativePath: path.join( context.globalConfig.localXpacksFolderName, dotBin ), packagePath: xPackFolderPath, }) // Remove the link, it should not throw, it was verified. await fs.unlink(xPackFolderPath) if (log.isVerbose) { log.verbose(`Symlink '${dependencyShownLocation}' removed`) } else { log.info(`'${dependencyShownLocation}' removed`) } const key = (scope ? `${scope}/` : '') + `${name}` this.updateDependencies(key) } else { log.info( `Symlink '${dependencyShownLocation}'` + ' should be removed' ) } } else { throw new CliError( `dependency '${dependencyShownLocation}' not a folder or a symlink` ) } return } if (this.policies.shareNpmDependencies) { const nodeFolderName = path.join() const nodeFolderPath = path.join( config.cwd, context.globalConfig.localNpmFolderName, scope, name ) try { stat = await fs.lstat(nodeFolderPath) } catch { if (config.isIgnoreErrors) { log.warn(`local package '${npmPackageSpecifier}' not installed`) return } throw new CliError( `local package '${npmPackageSpecifier}' not installed` ) } if (!stat.isSymbolicLink()) { throw new CliError(`symlink '${nodeFolderName}' not present`) } if (!config.isDryRun) { // Remove the corresponding bin links. await this.removeDotBinLinks({ xPacksBasePath, dotBinRelativePath: path.join( context.globalConfig.localNpmFolderName, dotBin ), packagePath: nodeFolderPath, }) // Remove the link, it should not throw, it was verified. await fs.unlink(nodeFolderPath) if (log.isVerbose) { log.verbose(`Symlink '${nodeFolderName}' removed`) } else { log.info(`'${nodeFolderName}' removed`) } const key = (scope ? `${scope}/` : '') + `${name}` this.updateDependencies(key) } else { log.info(`Symlink '${nodeFolderName}' should be removed`) } } } /** * @summary Uninstall a single package from the central store. * * @param {String} npmPackageSpecifier Packages specifier, * as [@scope/]name[@version]. * @returns {undefined|Promise} Nothing. * * @description * Remove the package from the global location. If the version is not * given, all versions are removed. */ async uninstallOnePackageGlobally(npmPackageSpecifier) { const log = this.log log.trace( `${this.constructor.name}.uninstallOnePackageGlobally` + `('${npmPackageSpecifier}')` ) const context = this.context const config = context.config // const configurationName = config.configurationName const { scope, name, version } = this.xpmPackage.parsePackageSpecifier({ npmPackageSpecifier, }) if (!name) { throw new CliErrorInput( `'${npmPackageSpecifier}' must include a package name` ) } const folderName = (scope ? `${scope}/` : '') + name + (version ? `/${version}` : '') const globalPackagePath = path.join( context.globalConfig.globalFolderPath, folderName ) let stat try { stat = await fs.stat(globalPackagePath) } catch { if (config.isIgnoreErrors) { log.warn(`global package '${npmPackageSpecifier}' not installed`) return } else { throw new CliError( `global package '${npmPackageSpecifier}' not installed` ) } } if (stat.isDirectory()) { if (!config.isDryRun) { log.verbose('Changing permissions to read-write...') await xpmLib.chmodRecursively({ inputPath: globalPackagePath, readOnly: false, log, }) await deleteAsync(globalPackagePath, { force: true }) log.info(`'${globalPackagePath}' removed`) } else { log.info(`Folder '${globalPackagePath}' should be removed`) } } else { throw new CliError(`'${globalPackagePath}' not a folder`) } } async uninstallPackageSystem(packSpec) { const log = this.log log.trace( `${this.constructor.name}.uninstallOnePackageSystem('${packSpec}')` ) throw new CliError('system uninstall not yet implemented') } // -------------------------------------------------------------------------- updateDependencies(key) { const log = this.log const context = this.context const config = context.config const configurationName = config.configurationName const jsonPackage = this.jsonPackage if (!config.doNotSave) { let jsonTarget if (configurationName) { assert(this.buildConfiguration) jsonTarget = this.buildConfiguration.jsonBuildConfiguration } else { if (this.policies.shareNpmDependencies) { jsonTarget = jsonPackage } else { // Starting with 0.14.x, dependencies are below xpack. jsonTarget = jsonPackage.xpack } } if (jsonTarget.dependencies && jsonTarget.dependencies[key]) { if (!config.isDryRun) { delete jsonTarget.dependencies[key] log.verbose(`package.json "dependencies['${key}']" removed`) config.mustRewritePackageJson = true } else { log.verbose( `Pretending to remove "dependencies['${key}']" ` + 'from package.json' ) } } if (jsonTarget.devDependencies && jsonTarget.devDependencies[key]) { if (!config.isDryRun) { delete jsonTarget.devDependencies[key] log.verbose(`package.json "devDependencies['${key}']" removed`) config.mustRewritePackageJson = true } else { log.verbose( `Pretending to remove "devDependencies['${key}']" ` + 'from package.json' ) } } } } /** * @summary Remove links to executables. * @returns {undefined} Nothing. * * @description * Iterate the entries in the package `executables` object and remove all * corresponding links from the `.bin` folder. */ async removeDotBinLinks({ xPacksBasePath, dotBinRelativePath, packagePath }) { const log = this.log const xpmPackage = new xpmLib.Package({ log, packageFolderPath: packagePath, }) const jsonPackage = await xpmPackage.readPackageDotJson() if (!jsonPackage) { return // Not a package (unlikely, but for just in case) } let packageExecutablesPath if (xpmPackage.isBinaryXpmPackage()) { // Since Nov. 2024, executables is preferred. packageExecutablesPath = jsonPackage.xpack.executables ?? jsonPackage.xpack.bin } else if (xpmPackage.isBinaryNodeModule()) { packageExecutablesPath = jsonPackage.bin } else { return // Has no executables/bins. } const executablesAbsolutePath = path.join( xPacksBasePath, dotBinRelativePath ) for (const key of Object.keys(packageExecutablesPath)) { const linkPath = path.join(executablesAbsolutePath, key) if (os.platform() === 'win32') { // On Windows there are two files for each binary, a `.cmd` shim // for the Windows console and a script for mingw-style terminals. try { const stat = await fs.stat(linkPath + '.cmd') if (stat.isFile()) { // Remove the file. await fs.unlink(linkPath + '.cmd') if (log.isVerbose) { log.verbose( `Shim '${path.join(dotBinRelativePath, key)}.cmd' removed` ) } else { log.info( `'${path.join(dotBinRelativePath, key)}.cmd'` + ' removed' ) } } else { // Not a file, preserve. } } catch { // Not present anyway, nothing to do. } try { const stat = await fs.stat(linkPath) if (stat.isFile()) { // Remove the file. await fs.unlink(linkPath) if (log.isVerbose) { log.verbose( 'Script ' + `'${path.join(dotBinRelativePath, key)}' removed` ) } else { log.info(`'${path.join(dotBinRelativePath, key)}' removed`) } } else { // Not a file, preserve. } } catch { // Not present anyway, nothing to do. } } else { // macOS and GNU/Linux platforms. try { const stat = await fs.lstat(linkPath) if (stat.isSymbolicLink()) { // Remove the link. await fs.unlink(linkPath) if (log.isVerbose) { log.verbose( `Symlink '${path.join(dotBinRelativePath, key)}' removed` ) } else { log.info(`'${path.join(dotBinRelativePath, key)}' removed`) } } else { // Not a link, preserve. } } catch { // Not present anyway, nothing to do. } } } } // -------------------------------------------------------------------------- } // ----------------------------------------------------------------------------