xpm
Version:
The xPack project manager command line tool
766 lines (695 loc) • 24.3 kB
text/typescript
/*
* This file is part of the xPack project (http://xpack.github.io).
* Copyright (c) 2021-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.
*/
// ----------------------------------------------------------------------------
import assert from 'node:assert'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
// https://www.npmjs.com/package/semver
import semver from 'semver'
// https://www.npmjs.com/package/@xpack/logger
import { Logger } from '@xpack/logger'
// ----------------------------------------------------------------------------
import { ConfigurationError, InputError, PrerequisitesError } from './errors.js'
import { isString } from '../functions/is-something.js'
import { hasLiquidSyntax } from '../functions/utils.js'
import {
JsonBuildConfiguration,
JsonBuildConfigurationContent,
JsonBuildConfigurationTemplate,
JsonPackageSpecifier,
JsonXpmPackage,
} from '../types/json.js'
// ============================================================================
/**
* Configuration parameters for constructing a package instance.
*
* @remarks
* This interface defines the required configuration for creating an
* instance of {@link Package}. Both properties are mandatory.
*
* The parameters provide the absolute path to the package folder containing
* (or that will contain) the <code>package.json</code> file, and the logger
* for diagnostic output during package operations.
*/
export interface PackageConstructorParameters {
/**
* The absolute path to the package folder.
*/
packageFolderPath: string
/**
* The logger instance for output and diagnostics.
*/
log: Logger
}
/**
* Provides access to package metadata and xpm-specific validation.
*
* @remarks
* This class loads and validates `package.json` content, determines
* package capabilities, and provides helper methods used across <b>xpm</b>
* workflows.
*
* The package abstraction provides a layer over `package.json` processing
* with progressive validation:
*
* <ol>
* <li><b>Basic file I/O:</b> Read and write <code>package.json</code> with
* error handling.</li>
* <li><b>npm validation:</b> Check for valid npm package structure (name,
* version).</li>
* <li><b>xpm validation:</b> Verify <code>xpack</code> section presence
* and structure.</li>
* <li><b>Binary package validation:</b> Validate binary-specific metadata
* (executables, binaries, platforms).</li>
* <li><b>Capability detection:</b> Determine package features (scripts,
* actions, build configurations).</li>
* <li><b>Version checking:</b> Validate minimum <b>xpm</b> version
* requirements.</li>
* <li><b>Specifier parsing:</b> Extract scope, name, and version from package
* identifiers.</li>
* </ol>
*
* This hierarchy allows validation to be performed incrementally as needed,
* avoiding unnecessary checks for packages that don't meet earlier criteria.
*/
export class Package {
// --------------------------------------------------------------------------
// Public Members.
/**
* The absolute path to the package folder.
*
* @remarks
* This path serves as the base folder for all package operations,
* including reading/writing `package.json` and resolving relative paths.
*
* Path requirements:
*
* <ol>
* <li>Must be an absolute path to a folder.</li>
* <li>Folder should contain (or will contain) a <code>package.json</code>
* file.</li>
* <li>Used to construct the path to <code>package.json</code> as
* <code>\{packageFolderPath\}/package.json</code>.</li>
* <li>Remains constant throughout the lifecycle of the
* <code>Package</code> instance.</li>
* </ol>
*
* The path is set during construction and used by all methods that access
* or modify `package.json`.
*/
packageFolderPath: string
/**
* The parsed `package.json` content, when available.
*
* @remarks
* This property caches the parsed `package.json` content after successful
* reading, avoiding repeated file I/O and parsing operations.
*
* Lifecycle states:
*
* <ol>
* <li>Initially undefined when the <code>Package</code> instance
* is created.</li>
* <li>Populated by <code>Package.readPackageDotJson()</code> upon
* successful read and parse.</li>
* <li>Cleared to undefined if parsing fails with
* <code>withThrow</code> enabled.</li>
* <li>Used by validation methods (<code>isNpmPackage</code>,
* <code>isxpm.Package</code>,
* <code>isBinaryXpmPackage</code>) to check package capabilities.</li>
* <li>Not automatically updated when <code>package.json</code> is
* modified externally;
* call <code>Package.readPackageDotJson()</code> again to refresh.</li>
* </ol>
*
* The cached content improves performance for packages that perform
* multiple validation checks without file system access overhead.
*/
jsonPackage?: JsonXpmPackage
// --------------------------------------------------------------------------
// Protected Members.
/**
* The logger instance for output and diagnostics.
*
* @remarks
* This logger provides trace-level diagnostics for package operations,
* including file I/O, parsing, validation, and version checking.
*
* Logging use cases:
*
* <ol>
* <li>Trace package folder path during construction.</li>
* <li>Log file read errors when investigating missing
* <code>package.json</code>.</li>
* <li>Trace JSON parsing errors for debugging invalid
* <code>package.json</code>.</li>
* <li>Log version validation details during <code>minimumXpmRequired</code>
* checks.</li>
* <li>Trace package specifier parsing for debugging dependency
* resolution.</li>
* </ol>
*
* The logger enables detailed diagnostics without affecting normal
* operation, as trace-level output is typically disabled in production.
*/
protected readonly _log: Logger
// --------------------------------------------------------------------------
// Constructor.
/**
* Constructs a package helper bound to a specific folder.
*
* @param packageFolderPath - The absolute path to the package folder.
* @param log - The logger instance for output and diagnostics.
*
* @throws {@link InputError}
* If packageFolderPath is not provided or is not an absolute path.
*/
constructor({ packageFolderPath, log }: PackageConstructorParameters) {
assert(
packageFolderPath && path.isAbsolute(packageFolderPath),
`packageFolderPath must be an absolute path, got: ${packageFolderPath}`
)
this._log = log
this.packageFolderPath = packageFolderPath
log.trace(`${Package.name}(${packageFolderPath})`)
}
// --------------------------------------------------------------------------
// Public Methods.
/**
* Reads and parses `package.json` from the package folder.
*
* @remarks
* This method provides flexible error handling for scenarios where a
* missing or invalid `package.json` may be expected (e.g., checking whether
* a folder is a package) versus scenarios where it indicates a critical
* error (e.g., operating on a known package).
*
* When `withThrow` is false, the method returns undefined for missing or
* invalid files, allowing callers to handle the absence gracefully. When
* `withThrow` is true, errors are thrown as {@link InputError} for
* consistent error handling across the application.
*
* @param withThrow - Whether to throw on missing or invalid `package.json`.
* @returns The parsed `package.json` content, or undefined when missing or
* invalid and `withThrow` is false.
*
* @throws {@link InputError}
* If `package.json` is missing or invalid and `withThrow` is true.
*/
async readPackageDotJson({
withThrow = false,
}: {
withThrow?: boolean
} = {}): Promise<JsonXpmPackage | undefined> {
const jsonFilePath = path.join(this.packageFolderPath, 'package.json')
let fileContent: string | Buffer
try {
fileContent = await fs.readFile(jsonFilePath)
} catch (error) {
if (withThrow) {
if (error instanceof Error) {
this._log.trace(error.message)
}
throw new InputError(
`no package.json in folder ‘${this.packageFolderPath}’`
)
} else {
return undefined
}
}
try {
this.jsonPackage = JSON.parse(fileContent.toString()) as JsonXpmPackage
} catch (error) {
if (withThrow) {
this.jsonPackage = undefined
if (error instanceof Error) {
this._log.trace(error.message)
}
throw new InputError(
`invalid package.json in folder ‘${this.packageFolderPath}’`
)
} else {
return undefined
}
}
return this.jsonPackage
}
/**
* Writes the provided `package.json` content to disk.
*
* @remarks
* The JSON content is passed explicitly rather than using the cached
* value.
*
* @param jsonPackage - The `package.json` content to write.
* @returns A promise that resolves when the file has been written.
*/
async rewritePackageDotJson(jsonPackage: JsonXpmPackage): Promise<void> {
const log = this._log
assert(jsonPackage, 'jsonPackage is required')
const jsonString = JSON.stringify(jsonPackage, null, 2) + '\n'
const jsonFilePath = path.join(this.packageFolderPath, 'package.json')
log.trace(`write filePath: '${jsonFilePath}'`)
await fs.writeFile(jsonFilePath, jsonString)
}
/**
* Determines whether the `package.json` content represents a valid
* npm package.
*
* @returns `true` if the package has a valid name and version, `false`
* otherwise.
*/
isNpmPackage(): boolean {
const jsonPackage = this.jsonPackage
if (!jsonPackage) {
return false
}
if (jsonPackage.name === undefined || jsonPackage.version === undefined) {
return false
}
const name = jsonPackage.name.trim()
if (name.length === 0) {
return false
}
const version = jsonPackage.version.trim()
if (version.length === 0) {
return false
}
return true
}
/**
* Determines whether the package is an <b>xpm</b> package.
*
* @returns `true` if the package is a valid npm package with an xpack
* section, `false` otherwise.
*/
isXpmPackage(): boolean {
const jsonPackage = this.jsonPackage
if (!this.isNpmPackage()) {
return false
}
if (jsonPackage?.xpack === undefined) {
return false
}
return true
}
/**
* Determines whether the package is a binary <b>xpm</b> package.
*
* @remarks
* Binary packages must have both executables and binaries. The
* presence of one implies the other, so this method validates consistency.
*
* Validation rules:
*
* <ol>
* <li>If <code>xpack.executables</code> (or deprecated
* <code>xpack.bin</code>) exists, then
* <code>xpack.binaries</code> and <code>xpack.binaries.platforms</code>
* must also exist.</li>
* <li>If <code>xpack.binaries</code> exists, then
* <code>xpack.binaries.platforms</code> and
* <code>xpack.executables</code> (or deprecated
* <code>xpack.bin</code>) must also exist.</li>
* </ol>
*
* This bidirectional validation ensures package metadata consistency and
* catches incomplete binary package configurations early. The check helps
* prevent runtime errors when attempting to install or use binary packages
* with missing metadata.
*
* @returns `true` if the package defines binaries and executables, `false`
* otherwise.
*
* @throws {@link InputError}
* If required binary package fields are missing.
*/
isBinaryXpmPackage() {
const jsonPackage = this.jsonPackage
if (!this.isXpmPackage()) {
return false
}
// Since Nov. 2024, `executables` is preferred to `bin`.
if (jsonPackage?.xpack.executables ?? jsonPackage?.xpack.bin) {
// If it has `executables` or `bin`, it must have `binaries` and
// `binaries.platforms` too.
if (!jsonPackage.xpack.binaries) {
throw new ConfigurationError(
"doesn't look like a proper binary xpm package, " +
'package.json has no "xpack.binaries"'
)
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!jsonPackage.xpack.binaries.platforms) {
throw new ConfigurationError(
"doesn't look like a proper binary xpm package, " +
'package.json has no "xpack.binaries.platforms"'
)
}
return true
}
if (jsonPackage?.xpack.binaries) {
// If it has `binaries`, it must have `binaries.platforms` and
// `executables` too.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!jsonPackage.xpack.binaries.platforms) {
throw new ConfigurationError(
"doesn't look like a proper binary xpm package, " +
'package.json has no "xpack.binaries.platforms"'
)
}
// if (!(jsonPackage.xpack.executables ?? jsonPackage.xpack.bin)) {
throw new ConfigurationError(
"doesn't look like a proper binary xpm package, " +
'package.json has no "xpack.executables"'
)
//}
//return true
}
return false
}
/**
* Determines whether the package is a Node module without <b>xpm</b>
* metadata.
*
* @returns `true` if the package is a Node module without <b>xpm</b>
* metadata, `false` otherwise.
*/
isNodeModule() {
const jsonPackage = this.jsonPackage
if (!this.isNpmPackage()) {
return false
}
if (jsonPackage?.xpack) {
return false
}
return true
}
/**
* Determines whether the package is a Node module with a binary entry.
*
* @returns `true` if the package is a Node module with a bin entry,
* `false` otherwise.
*/
isBinaryNodeModule() {
const jsonPackage = this.jsonPackage
if (!this.isNodeModule()) {
return false
}
if (jsonPackage?.bin === undefined) {
return false
}
return true
}
/**
* Determines whether the package defines any npm scripts.
*
* @returns `true` if at least one script is defined, `false` otherwise.
*/
hasNpmScripts(): boolean {
const jsonPackage = this.jsonPackage
if (
jsonPackage?.scripts !== undefined &&
Object.keys(jsonPackage.scripts).length > 0
) {
return true
}
return false
}
/**
* Determines whether the package defines any <b>xpm</b> actions.
*
* @remarks
* This method performs a comprehensive search for action definitions at
* both the package level and within build configurations, including
* template-based configurations.
*
* Action detection strategy:
*
* <ol>
* <li>Check for package-level actions in <code>xpack.actions</code>.</li>
* <li>If no package-level actions, iterate through all build
* configurations.</li>
* <li>For each configuration, determine if it's a template (name contains
* Liquid syntax) or a regular configuration.</li>
* <li>For templates: Check <code>template.actions</code> for action
* definitions.</li>
* <li>For regular configurations: Check <code>actions</code> directly.</li>
* <li>Return true if any actions are found at any level.</li>
* </ol>
*
* This comprehensive check is useful for determining whether <b>xpm</b>
* action
* commands should be available or whether the package requires <b>xpm</b> for
* build automation.
*
* @returns `true` if actions are defined directly or within build
* configurations, `false` otherwise.
*/
hasXpmActions(): boolean {
const json = this.jsonPackage
try {
if (
json?.xpack.actions !== undefined &&
Object.keys(json.xpack.actions).length > 0
) {
return true
}
if (
json?.xpack.buildConfigurations !== undefined &&
Object.keys(json.xpack.buildConfigurations).length > 0
) {
for (const buildConfigurationName of Object.keys(
json.xpack.buildConfigurations
)) {
const buildConfiguration: JsonBuildConfiguration =
json.xpack.buildConfigurations[buildConfigurationName]
if (hasLiquidSyntax(buildConfigurationName)) {
const buildConfigurationTemplate =
buildConfiguration as JsonBuildConfigurationTemplate
if (
buildConfigurationTemplate.template.actions !== undefined &&
Object.keys(buildConfigurationTemplate.template.actions).length >
0
) {
return true
}
} else {
const buildConfigurationContent =
buildConfiguration as JsonBuildConfigurationContent
if (
buildConfigurationContent.actions !== undefined &&
Object.keys(buildConfigurationContent.actions).length > 0
) {
return true
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// In case xpack is not an option to get its properties.
}
return false
}
/**
* Retrieves the minimum required <b>xpm</b> version specified by the package.
*
* @returns The minimum required <b>xpm</b> version without pre-release
* suffixes, or
* undefined if not specified.
*/
getMinimumXpmRequired(): string | undefined {
const log = this._log
const jsonPackage = this.jsonPackage
log.trace(`${Package.name}.getMinimumXpmRequired()`)
const version = jsonPackage?.xpack.minimumXpmRequired
if (version === undefined) {
return undefined
}
if (!isString(version)) {
return undefined
}
// Remove the pre-release part.
return version.replace(/-.*$/, '')
}
/**
* Validates the minimum required <b>xpm</b> version against the
* installed CLI.
*
* @remarks
* This method ensures that packages requiring specific <b>xpm</b>
* features or bug
* fixes can enforce a minimum version requirement, preventing runtime
* errors or unexpected behavior with older <b>xpm</b> versions.
*
* Validation workflow:
*
* <ol>
* <li>Check if package is an <b>xpm</b> package with
* <code>minimumXpmRequired</code> set.</li>
* <li>Clean the required version by removing pre-release suffixes.</li>
* <li>Load the <b>xpm</b> CLI's <code>package.json</code> from the
* provided root folder.</li>
* <li>Extract and clean the installed <b>xpm</b> version.</li>
* <li>Compare versions using semver to determine if upgrade is needed.</li>
* <li>Throw <code>PrerequisitesError</code> if installed version is
* too old.</li>
* </ol>
*
* Pre-release suffixes are stripped from both versions to ensure that
* pre-release builds satisfy version requirements (e.g., 1.0.0-beta
* satisfies minimumXpmRequired: 1.0.0).
*
* @param xpmRootFolderPath - The folder path to the <b>xpm</b> CLI package.
* @returns The cleaned minimum required version, or undefined if no check is
* required.
*
* @throws {@link PrerequisitesError}
* If the installed <b>xpm</b> version is lower than the required minimum.
*/
async checkMinimumXpmRequired({
xpmRootFolderPath,
}: {
xpmRootFolderPath: string
}): Promise<string | undefined> {
const log = this._log
const jsonPackage = this.jsonPackage
log.trace(`${Package.name}.checkMinimumXpmRequired()`)
if (!this.isXpmPackage()) {
// Not in an xpm package.
return undefined
}
const minimumXpmRequired = this.getMinimumXpmRequired()
if (!minimumXpmRequired) {
log.trace('minimumXpmRequired not used, no checks')
return undefined
}
log.trace(`minimumXpmRequired: ${minimumXpmRequired}`)
let jsonXpmCliPackage: JsonXpmPackage | undefined
try {
const cliXpmPackage = new Package({
log,
packageFolderPath: xpmRootFolderPath,
})
jsonXpmCliPackage = await cliXpmPackage.readPackageDotJson({
withThrow: true,
})
} catch (error) {
if (error instanceof Error) {
log.trace(error.message)
// Safety net: This handles non-Error exceptions. Node.js fs operations
// and the Package class consistently throw Error instances, but this
// provides defensive handling for unexpected error types that might
// occur in edge cases or future code changes.
/* c8 ignore start - safety net, currently all are Errors */
} else {
log.trace(error)
}
/* c8 ignore stop */
return undefined
}
assert(jsonXpmCliPackage, 'jsonXpmCliPackage is required')
log.trace(jsonXpmCliPackage.version)
if (!jsonXpmCliPackage.version) {
return undefined
}
// Remove the pre-release part.
const xpmVersion = semver.clean(
jsonXpmCliPackage.version.replace(/-.*$/, '')
)
if (!xpmVersion) {
return undefined
}
if (semver.lt(xpmVersion, minimumXpmRequired)) {
assert(jsonPackage?.name, 'jsonPackage.name is required')
throw new PrerequisitesError(
`package '${jsonPackage.name}' ` +
`requires xpm v${minimumXpmRequired} or later, please upgrade`
)
}
// Check passed.
return minimumXpmRequired
}
/**
* Parses an npm package specifier into its components.
*
* @remarks
* npm package specifiers can take several forms:
*
* <ul>
* <li><b>Unscoped without version:</b> <code>package-name</code></li>
* <li><b>Unscoped with version:</b> <code>package-name\@1.2.3</code></li>
* <li><b>Scoped without version:</b> <code>\@scope/package-name</code></li>
* <li><b>Scoped with version:</b>
* <code>\@scope/package-name\@1.2.3</code></li>
* </ul>
*
* Parsing strategy:
*
* <ol>
* <li>If specifier starts with <code>\@</code>, extract scope and handle
* scoped format.</li>
* <li>Split on <code>/</code> to separate scope from name\@version.</li>
* <li>Split the second part on <code>\@</code> to separate name from
* version.</li>
* <li>For unscoped packages, split directly on <code>\@</code> to separate
* name from version.</li>
* </ol>
*
* The parser handles all valid npm package specifier formats and returns
* structured components for downstream processing. Invalid formats with
* multiple slashes are rejected.
*
* @param npmPackageSpecifier - The npm package specifier to parse.
* @returns The parsed package specifier components.
*
* @throws {@link InputError}
* If the specifier is not a valid package name format.
*/
parsePackageSpecifier({
npmPackageSpecifier,
}: {
npmPackageSpecifier: string
}): JsonPackageSpecifier {
assert(npmPackageSpecifier, 'npmPackageSpecifier is required')
const log = this._log
let scope
let name
let version
if (npmPackageSpecifier.startsWith('@')) {
const arr = npmPackageSpecifier.split('/')
if (arr.length > 2) {
throw new InputError(`'${npmPackageSpecifier}' 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 = npmPackageSpecifier.split('@')
name = arr2[0]
if (arr2.length > 1) {
version = arr2[1]
}
}
log.trace(
`${npmPackageSpecifier} => ` +
`${scope ?? '?'} ${name ?? '?'} ${version ?? '?'}`
)
return { scope, name, version }
}
}
// ----------------------------------------------------------------------------