xpm
Version:
The xPack project manager command line tool
431 lines (359 loc) • 12.7 kB
JavaScript
/*
* This file is part of the xPack project (http://xpack.github.io).
* Copyright (c) 2020-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.
*/
// ----------------------------------------------------------------------------
/**
* The `xpm link ...` command implementation.
*/
// ----------------------------------------------------------------------------
// https://nodejs.org/docs/latest/api/
import fs from 'fs/promises'
import util from 'util'
import path from 'path'
import os from 'os'
// ----------------------------------------------------------------------------
// https://www.npmjs.com/package/del
import { deleteAsync } from 'del'
// https://www.npmjs.com/package/make-dir
import { makeDirectory } from 'make-dir'
// ----------------------------------------------------------------------------
// import { CliCommand, CliError, CliErrorInput, CliExitCodes }
// 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 { GlobalConfig } from '../classes/global-config.js'
import { ManifestIds } from '../classes/manifest-ids.js'
import { convertXpmError } from '../functions/convert-xpm-errors.js'
// ----------------------------------------------------------------------------
const { CliCommand, CliError, CliErrorInput, CliExitCodes } = cliStartOptionsCsj
const dotLink = '.link'
// ============================================================================
export class Link 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 - ' + 'create links to packages under development'
this.optionGroups = [
{
title: 'Link options',
postOptions: '[[@<scope>/]<name>]', // Extra arguments.
optionDefs: [
{
options: ['-c', '--config'],
init: ({ config }) => {
config.configurationName = undefined
},
action: ({ config }, val) => {
config.configurationName = val.trim()
},
msg: 'Link to the configuration build folder',
param: 'config_name',
isOptional: true,
},
],
},
]
}
/**
* @summary Execute the `link` command.
*
* @param {string[]} args Command line arguments.
* @returns {number|Promise} Return code.
*
* @override
*/
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()
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
try {
// Read `package.json`; throw if not valid.
this.jsonPackage = await xpmPackage.readPackageDotJson({
withThrow: true,
})
} catch (error) {
throw convertXpmError(error)
}
const jsonPackage = this.jsonPackage
if (!xpmPackage.isXpmPackage()) {
throw new CliErrorInput(
'current folder is not an xpm package, ' +
'check for the "xpack" property in package.json'
)
}
try {
const minVersion = await xpmPackage.checkMinimumXpmRequired({
xpmRootFolderPath: context.rootPath,
})
this.policies = new xpmLib.Policies({ log, minVersion })
} catch (error) {
throw convertXpmError(error)
}
if (args.length === 0) {
if (!jsonPackage.isNpmPackage()) {
throw new CliErrorInput(
'check for mandatory "name" and "version" properties in package.json'
)
}
await this.createLinkFromRepoToHere()
} else {
await this.createLinkToRepoPackage(args)
}
if (log.isVerbose) {
this.outputDoneDuration()
}
return CliExitCodes.SUCCESS
}
/*
* `xpm link
* Create a development link from global storage to the current package.
*/
async createLinkFromRepoToHere() {
const log = this.log
log.trace(`${this.constructor.name}.createLinkFromRepoToHere()`)
const context = this.context
const config = context.config
const configurationName = config.configurationName
const xpmPackage = this.xpmPackage
if (configurationName) {
throw new CliErrorInput('misplaced --config')
}
const jsonPackage = this.jsonPackage
this.manifestIds = new ManifestIds({
manifest: jsonPackage,
policies: this.policies,
})
const globalPackagePath = path.join(
context.globalConfig.globalFolderPath,
this.manifestIds.getScopedName()
)
const globalPackageLinkPath = path.join(globalPackagePath, dotLink)
let stats
try {
// Use `lstat`, since `stat` follows the links.
stats = await fs.lstat(globalPackageLinkPath)
} catch {
// `lstat` failed, the path does not exist; proceed to create the link.
stats = null
}
if (stats) {
if (stats.isSymbolicLink()) {
try {
log.trace(`del('${globalPackageLinkPath}')`)
await deleteAsync(globalPackageLinkPath, { force: true })
} catch (error) {
log.trace(util.inspect(error))
throw new CliError(`cannot remove '${globalPackageLinkPath}'`)
}
} else {
throw new CliError(`'${globalPackageLinkPath}' is not a symbolic link`)
}
}
// Create parent folder, for just in case.
await makeDirectory(path.dirname(globalPackageLinkPath))
// fs.symlink(target, path[, type], callback)
// 'creates the link called path pointing to target'
log.trace(
`symlink('${xpmPackage.packageFolderPath}', '${globalPackageLinkPath})'`
)
if (os.platform() === 'win32') {
await fs.symlink(
xpmPackage.packageFolderPath,
globalPackageLinkPath,
'junction'
)
} else {
await fs.symlink(xpmPackage.packageFolderPath, globalPackageLinkPath)
}
if (log.isVerbose) {
log.info(
'Development references to package ' +
`'${this.manifestIds.getScopedName()}' will be redirected ` +
`to folder '${xpmPackage.packageFolderPath}'`
)
} else {
log.info(
`${this.manifestIds.getScopedName()} -> ` +
`'${xpmPackage.packageFolderPath}'`
)
}
}
/*
* `xpm link <package>
* Create a link from the current package/configuration to the global
* development link.
*/
async createLinkToRepoPackage(args) {
const log = this.log
log.trace(`${this.constructor.name}.createLinkFromRepoToHere()`)
const context = this.context
const config = context.config
const configurationName = config.configurationName
for (const arg of args) {
const globalPackageLinkPath = path.join(
context.globalConfig.globalFolderPath,
arg,
dotLink
)
let stats
try {
stats = await fs.lstat(globalPackageLinkPath)
} catch (error) {
log.trace(util.inspect(error))
throw new CliErrorInput(
`there is no development link for package '${arg}'`
)
}
if (!stats.isSymbolicLink()) {
throw new CliErrorInput(
`there is no development link for package '${arg}'`
)
}
// Follow the link and check the destination.
try {
stats = await fs.stat(globalPackageLinkPath)
} catch (error) {
log.trace(util.inspect(error))
throw new CliError(`broken link '${globalPackageLinkPath}'`)
}
if (!stats.isDirectory()) {
throw new CliErrorInput(`package '${arg}' is not linked to a folder`)
}
const destinationXpmPackage = new xpmLib.Package({
log,
packageFolderPath: globalPackageLinkPath,
})
let jsonDestination
try {
jsonDestination = await destinationXpmPackage.readPackageDotJson({
withThrow: true,
})
} catch (error) {
// log.trace(util.inspect(err))
throw convertXpmError(error)
}
if (!destinationXpmPackage.isXpmPackage()) {
throw new CliErrorInput(
`${arg}' does not link to an xpm package, ` +
'check for the "xpack" property in package.json'
)
}
const destManifestIds = new ManifestIds({
manifest: jsonDestination,
policies: this.policies,
})
let localXpacksFolderPath
if (configurationName) {
const xpmDataModel = new xpmLib.DataModel({
log,
jsonPackage: this.jsonPackage,
})
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)
await buildConfiguration.initialise()
const buildFolderRelativePath =
buildConfiguration.buildFolderRelativePath
localXpacksFolderPath = path.join(
config.cwd,
buildFolderRelativePath,
context.globalConfig.localXpacksFolderName
)
} else {
// Top-level package link.
localXpacksFolderPath = path.join(
config.cwd,
context.globalConfig.localXpacksFolderName
)
}
log.trace(`localXpacksFolderPath: ${localXpacksFolderPath}`)
const localXpacksLinkName = destManifestIds.getFolderName()
const localXpacksLinkPath = path.join(
localXpacksFolderPath,
localXpacksLinkName
)
log.trace(`localXpacksLinkName: ${localXpacksLinkName}`)
log.trace(`localXpacksLinkPath: ${localXpacksLinkPath}`)
try {
// Use `lstat`, since `stat` follows the links.
stats = await fs.lstat(localXpacksLinkPath)
} catch {
stats = null
// `lstat` failed, the path does not exist; proceed to create the link.
}
if (stats) {
if (!stats.isSymbolicLink()) {
throw new CliError(
`'${context.globalConfig.localXpacksFolderName}` +
`/${localXpacksLinkName}' is not a symbolic link; ` +
'preserved, it might contain important code'
)
}
try {
log.trace(`del('${localXpacksLinkPath}')`)
await deleteAsync(localXpacksLinkPath, { force: true })
} catch (error) {
log.trace(util.inspect(error))
throw new CliError(`cannot remove '${localXpacksLinkPath}'`)
}
}
// Create parent folder, for just in case.
await makeDirectory(path.dirname(localXpacksLinkPath))
// fs.symlink(target, path[, type], callback)
// 'creates the link called path pointing to target'
log.trace(
'symlink' + `('${globalPackageLinkPath}', '${localXpacksLinkPath})'`
)
if (os.platform() === 'win32') {
await fs.symlink(globalPackageLinkPath, localXpacksLinkPath, 'junction')
} else {
await fs.symlink(globalPackageLinkPath, localXpacksLinkPath)
}
const destRealPath = await fs.realpath(globalPackageLinkPath)
if (log.isVerbose) {
log.info(
`Local reference to '${destManifestIds.getScopedName()}' ` +
`redirected to the development folder '${destRealPath}'`
)
} else {
log.info(`${destManifestIds.getScopedName()} ` + `-> '${destRealPath}'`)
}
}
}
// --------------------------------------------------------------------------
}
// ----------------------------------------------------------------------------