/*
* 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 package.json 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 xpm
* workflows.
*
* The package abstraction provides a layer over `package.json` processing
* with progressive validation:
*
*
* - Basic file I/O: Read and write
package.json with
* error handling.
* - npm validation: Check for valid npm package structure (name,
* version).
* - xpm validation: Verify
xpack section presence
* and structure.
* - Binary package validation: Validate binary-specific metadata
* (executables, binaries, platforms).
* - Capability detection: Determine package features (scripts,
* actions, build configurations).
* - Version checking: Validate minimum xpm version
* requirements.
* - Specifier parsing: Extract scope, name, and version from package
* identifiers.
*
*
* 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:
*
*
* - Must be an absolute path to a folder.
* - Folder should contain (or will contain) a
package.json
* file.
* - Used to construct the path to
package.json as
* \{packageFolderPath\}/package.json.
* - Remains constant throughout the lifecycle of the
*
Package instance.
*
*
* 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:
*
*
* - Initially undefined when the
Package instance
* is created.
* - Populated by
Package.readPackageDotJson() upon
* successful read and parse.
* - Cleared to undefined if parsing fails with
*
withThrow enabled.
* - Used by validation methods (
isNpmPackage,
* isxpm.Package,
* isBinaryXpmPackage) to check package capabilities.
* - Not automatically updated when
package.json is
* modified externally;
* call Package.readPackageDotJson() again to refresh.
*
*
* 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:
*
*
* - Trace package folder path during construction.
* - Log file read errors when investigating missing
*
package.json.
* - Trace JSON parsing errors for debugging invalid
*
package.json.
* - Log version validation details during
minimumXpmRequired
* checks.
* - Trace package specifier parsing for debugging dependency
* resolution.
*
*
* 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 {
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 {
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 xpm 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 xpm package.
*
* @remarks
* Binary packages must have both executables and binaries. The
* presence of one implies the other, so this method validates consistency.
*
* Validation rules:
*
*
* - If
xpack.executables (or deprecated
* xpack.bin) exists, then
* xpack.binaries and xpack.binaries.platforms
* must also exist.
* - If
xpack.binaries exists, then
* xpack.binaries.platforms and
* xpack.executables (or deprecated
* xpack.bin) must also exist.
*
*
* 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 xpm
* metadata.
*
* @returns `true` if the package is a Node module without xpm
* 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 xpm 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:
*
*
* - Check for package-level actions in
xpack.actions.
* - If no package-level actions, iterate through all build
* configurations.
* - For each configuration, determine if it's a template (name contains
* Liquid syntax) or a regular configuration.
* - For templates: Check
template.actions for action
* definitions.
* - For regular configurations: Check
actions directly.
* - Return true if any actions are found at any level.
*
*
* This comprehensive check is useful for determining whether xpm
* action
* commands should be available or whether the package requires xpm 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 xpm version specified by the package.
*
* @returns The minimum required xpm 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 xpm version against the
* installed CLI.
*
* @remarks
* This method ensures that packages requiring specific xpm
* features or bug
* fixes can enforce a minimum version requirement, preventing runtime
* errors or unexpected behavior with older xpm versions.
*
* Validation workflow:
*
*
* - Check if package is an xpm package with
*
minimumXpmRequired set.
* - Clean the required version by removing pre-release suffixes.
* - Load the xpm CLI's
package.json from the
* provided root folder.
* - Extract and clean the installed xpm version.
* - Compare versions using semver to determine if upgrade is needed.
* - Throw
PrerequisitesError if installed version is
* too old.
*
*
* 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 xpm CLI package.
* @returns The cleaned minimum required version, or undefined if no check is
* required.
*
* @throws {@link PrerequisitesError}
* If the installed xpm version is lower than the required minimum.
*/
async checkMinimumXpmRequired({
xpmRootFolderPath,
}: {
xpmRootFolderPath: string
}): Promise {
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:
*
*
* - Unscoped without version:
package-name
* - Unscoped with version:
package-name\@1.2.3
* - Scoped without version:
\@scope/package-name
* - Scoped with version:
*
\@scope/package-name\@1.2.3
*
*
* Parsing strategy:
*
*
* - If specifier starts with
\@, extract scope and handle
* scoped format.
* - Split on
/ to separate scope from name\@version.
* - Split the second part on
\@ to separate name from
* version.
* - For unscoped packages, split directly on
\@ to separate
* name from version.
*
*
* 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 }
}
}
// ----------------------------------------------------------------------------