UNPKG

xpm

Version:

The xPack project manager command line tool

766 lines (695 loc) 24.3 kB
/* * 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 } } } // ----------------------------------------------------------------------------