/* * 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: * *
    *
  1. Basic file I/O: Read and write package.json with * error handling.
  2. *
  3. npm validation: Check for valid npm package structure (name, * version).
  4. *
  5. xpm validation: Verify xpack section presence * and structure.
  6. *
  7. Binary package validation: Validate binary-specific metadata * (executables, binaries, platforms).
  8. *
  9. Capability detection: Determine package features (scripts, * actions, build configurations).
  10. *
  11. Version checking: Validate minimum xpm version * requirements.
  12. *
  13. Specifier parsing: Extract scope, name, and version from package * identifiers.
  14. *
* * 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: * *
    *
  1. Must be an absolute path to a folder.
  2. *
  3. Folder should contain (or will contain) a package.json * file.
  4. *
  5. Used to construct the path to package.json as * \{packageFolderPath\}/package.json.
  6. *
  7. Remains constant throughout the lifecycle of the * Package instance.
  8. *
* * 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: * *
    *
  1. Initially undefined when the Package instance * is created.
  2. *
  3. Populated by Package.readPackageDotJson() upon * successful read and parse.
  4. *
  5. Cleared to undefined if parsing fails with * withThrow enabled.
  6. *
  7. Used by validation methods (isNpmPackage, * isxpm.Package, * isBinaryXpmPackage) to check package capabilities.
  8. *
  9. Not automatically updated when package.json is * modified externally; * call Package.readPackageDotJson() again to refresh.
  10. *
* * 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: * *
    *
  1. Trace package folder path during construction.
  2. *
  3. Log file read errors when investigating missing * package.json.
  4. *
  5. Trace JSON parsing errors for debugging invalid * package.json.
  6. *
  7. Log version validation details during minimumXpmRequired * checks.
  8. *
  9. Trace package specifier parsing for debugging dependency * resolution.
  10. *
* * 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: * *
    *
  1. If xpack.executables (or deprecated * xpack.bin) exists, then * xpack.binaries and xpack.binaries.platforms * must also exist.
  2. *
  3. If xpack.binaries exists, then * xpack.binaries.platforms and * xpack.executables (or deprecated * xpack.bin) must also exist.
  4. *
* * 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: * *
    *
  1. Check for package-level actions in xpack.actions.
  2. *
  3. If no package-level actions, iterate through all build * configurations.
  4. *
  5. For each configuration, determine if it's a template (name contains * Liquid syntax) or a regular configuration.
  6. *
  7. For templates: Check template.actions for action * definitions.
  8. *
  9. For regular configurations: Check actions directly.
  10. *
  11. Return true if any actions are found at any level.
  12. *
* * 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: * *
    *
  1. Check if package is an xpm package with * minimumXpmRequired set.
  2. *
  3. Clean the required version by removing pre-release suffixes.
  4. *
  5. Load the xpm CLI's package.json from the * provided root folder.
  6. *
  7. Extract and clean the installed xpm version.
  8. *
  9. Compare versions using semver to determine if upgrade is needed.
  10. *
  11. Throw PrerequisitesError if installed version is * too old.
  12. *
* * 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: * *
    *
  1. If specifier starts with \@, extract scope and handle * scoped format.
  2. *
  3. Split on / to separate scope from name\@version.
  4. *
  5. Split the second part on \@ to separate name from * version.
  6. *
  7. For unscoped packages, split directly on \@ to separate * name from version.
  8. *
* * 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 } } } // ----------------------------------------------------------------------------