/*
* 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 os from 'node:os'
import { Logger } from '@xpack/logger'
// ----------------------------------------------------------------------------
import {
LiquidSubstitutionsVariables,
LiquidSubstitutionsStrings,
} from '../data/substitutions-variables.js'
import {
isJsonObject,
isString,
isJsonArray,
} from '../functions/is-something.js'
import { performSubstitutions } from '../functions/perform-substitutions.js'
import { getErrorMessage, hasLiquidSyntax } from '../functions/utils.js'
import {
JsonActionContent,
JsonActions,
JsonActionTemplate,
} from '../types/json.js'
import { BuildConfiguration } from './build-configurations.js'
import { ConfigurationError } from './errors.js'
import { LiquidEngine } from './liquid-engine.js'
import { TemplateExpander } from './template-expander.js'
// ============================================================================
/**
* Configuration parameters for constructing an actions collection instance.
*
* @remarks
* This interface defines the required configuration for creating an
* instance of {@link Actions}. Most properties are mandatory except for
* the optional inheritedActionsMap and
* buildConfiguration parameters.
*
* The parameters provide the actions collection with access to the Liquid
* templating engine, substitution variables hierarchy, action definitions
* from the package manifest, optional inherited actions from a parent
* package, optional build configuration context, and the logger for
* diagnostic output.
*/
export interface ActionsConstructorParameters {
/**
* The Liquid templating engine for variable substitution.
*/
engine: LiquidEngine
/**
* The variables available for substitution in action definitions.
*/
substitutionsVariables: LiquidSubstitutionsVariables
/**
* The JSON object containing action definitions, or undefined if there are
* no actions.
*/
jsonActions: JsonActions | undefined
/**
* Optional map of actions inherited from a parent package.
*/
inheritedActionsMap?: Map
/**
* Optional build configuration this actions collection belongs to.
*/
buildConfiguration?: BuildConfiguration
/**
* The logger instance for output and diagnostics.
*/
log: Logger
}
/**
* A collection of xpm actions for a build configuration or
* the entire package.
*
* @remarks
* This class manages a collection of named actions, each containing one or
* more commands to be executed. Actions can belong to a package or a build
* configuration and support template-based definitions with matrix expansion
* to generate multiple actions from a single template.
*
* The collection always exists, even as empty if no actions are defined.
*
* Action lifecycle phases:
*
*
* - Construction: Basic setup with optional inheritance from parent
* package.
* - Initialisation: Template name expansion without content
* evaluation.
* - Retrieval: On-demand instantiation when accessed via
*
get().
* - Action Initialisation: Liquid template evaluation and
* substitution.
*
*
* This multi-phase approach ensures efficient resource usage by deferring
* expensive operations until actions are actually needed.
*/
export class Actions {
// --------------------------------------------------------------------------
// Public Members.
/**
* The logger instance for output and diagnostics.
*
* @remarks
* This logger is used throughout the lifecycle of actions collection to
* provide trace-level diagnostics for debugging template expansion, action
* instantiation, and variable substitution. It enables visibility into the
* lazy evaluation process without impacting runtime performance when tracing
* is disabled.
*/
readonly log: Logger
/**
* The Liquid templating engine for variable substitution.
*
* @remarks
* This engine instance is shared across all actions in the collection and
* configured with custom filters for platform detection, path manipulation,
* and xpm-specific operations. It's used during both template action name
* expansion and later during individual action command substitution,
* ensuring consistent template processing throughout the action lifecycle.
*/
readonly engine: LiquidEngine
/**
* The variables available for substitution in action definitions.
*
* @remarks
* This comprehensive variable hierarchy provides context for template
* evaluation, including package metadata, build configuration properties,
* environment variables, platform detection, and path utilities.
*
* The hierarchy structure:
*
*
* - Base variables:
env, os,
* path (always available).
* - Package variables:
name, version,
* dependencies,
* devDependencies.
* - Configuration variables: build folder paths, compiler
* settings.
* - Properties: custom key-value pairs from package or
* configuration.
* - Matrix: parameter combinations for template-generated
* actions (added per action during initialisation).
*
*
* These variables are accessible in Liquid templates using dot notation
* (e.g., `{{ package.name }}`,
* `{{ configuration.buildFolderRelativePath }}`).
*/
readonly substitutionsVariables: LiquidSubstitutionsVariables
/**
* The JSON object containing action definitions from the package manifest.
*
* @remarks
* This object holds the raw action definitions as they appear in the
* `package.json` `xpack.actions` section or within a build configuration's
* actions. Action definitions can be:
*
*
* - Simple strings: Single command to execute.
* - String arrays: Multiple commands executed sequentially.
* - Template objects: With
matrix and
* template properties for
* generating multiple actions from a single definition.
*
*
* Template action names (containing `{{` markers) trigger matrix expansion
* during initialisation, creating concrete actions from the Cartesian
* product of matrix parameter values.
*/
readonly jsonActions: JsonActions
/**
* The build configuration this actions collection belongs to, if any.
*
* @remarks
* This optional reference establishes the hierarchical relationship between
* actions and build configurations, affecting variable substitution scope
* and action inheritance.
*
* When defined:
*
*
* - Actions inherit configuration-specific variables (build folder paths,
* compiler settings, toolchain properties).
* - Actions belong to a specific configuration namespace rather than the
* package root.
* - Logging and diagnostics include the configuration name for
* context.
*
*
* When `undefined`:
*
*
* - Actions belong to the package root (
xpack.actions in
* package.json).
* - Only package-level and global variables are available for
* substitution.
*
*/
readonly buildConfiguration: BuildConfiguration | undefined
// --------------------------------------------------------------------------
// Protected Members.
/**
* Map of action names to their corresponding action instances.
*
* @remarks
* This map serves as the primary action registry, populated during
* collection initialisation with entries for all discovered actions.
*
* Key characteristics:
*
*
* - Known only after
Actions.initialise()
* completes.
* - Possibly empty if there are no actions defined.
* - Values can be
undefined to indicate an action
* exists but hasn't
* been instantiated yet (lazy loading).
* - For template actions, contains one entry per expanded combination,
* not the original template definition.
*
*
* Actions transition from `undefined` to instantiated when first accessed
* via {@link Actions.get}, implementing the lazy evaluation
* pattern.
*/
protected readonly _actionsMap: Map = new Map<
string,
Action | undefined
>()
/**
* Set of all action names for quick lookup.
*
* @remarks
* This set provides O(1) existence checks for action names, enabling
* efficient validation during template expansion and duplicate detection.
*
* Key characteristics:
*
*
* - Known only after
Actions.initialise()
* completes.
* - Contains all action names including those generated from
* templates.
* - Used to detect duplicate action names that might arise from template
* expansion conflicts or explicit duplicates in
*
package.json.
*
*
* This redundant storage (alongside `_actionsMap`) is justified by the
* performance benefit for name existence checks, especially in packages
* with many actions.
*/
protected readonly _namesSet: Set = new Set()
/**
* Map of expanded action names to their original JSON action names.
*
* @remarks
* This reverse mapping enables retrieving the original action definition
* from `jsonActions` when lazy-loading action instances.
*
* Mapping behavior:
*
*
* - For regular actions: Maps action name to itself (identity
* mapping).
* - For template actions: Maps each generated action name back to
* the original template name (e.g.,
*
test-x64 → test-\{\{ matrix.arch \}\}).
* - Enables
Actions.get() to locate the correct JSON
* definition when instantiating an action on demand.
*
*
* This indirection is essential for the lazy evaluation pattern, allowing
* deferred instantiation while maintaining the connection to original
* definitions.
*/
protected readonly _jsonActionsNamesMap: Map = new Map<
string,
string
>()
/**
* Flag indicating whether the actions collection has been initialised.
*
* @remarks
* This flag prevents redundant initialisation and ensures idempotent
* behavior when {@link Actions.initialise} is called multiple
* times.
*
* State transitions:
*
*
* - Initially
false after construction.
* - Set to
true after successful template expansion and
* action name
* registration.
* - Checked at the beginning of
Actions.initialise() to
* return early if already initialised.
*
*
* This pattern supports safe repeated calls during complex initialisation
* sequences without duplicating work or corrupting internal state.
*/
protected _isInitialised = false
/**
* Cached array of all action names in the collection.
*
* @remarks
* This array provides O(1) access to action names without repeatedly
* creating new arrays from the map keys, improving performance when the
* names are accessed multiple times.
*
* Key characteristics:
*
*
* - Empty initially after construction.
* - Populated during
Actions.initialise() after all
* action names
* are determined.
* - Contains all action names including those generated from
* templates.
* - Returned by the
names getter for efficient repeated
* access.
*
*
* This cached approach avoids the overhead of calling
* `Array.from(map.keys())` on every access whilst still
* providing a clean getter interface.
*/
protected _names: string[] = []
// --------------------------------------------------------------------------
// Constructor and async initialiser.
/**
* Constructs an actions collection instance.
*
* @remarks
* The constructor performs partial initialisation. Complete initialisation
* requires calling the `Actions.initialise()` method.
*
* @param log - The logger instance for output and diagnostics.
*/
constructor({
engine,
substitutionsVariables,
jsonActions,
inheritedActionsMap,
buildConfiguration,
log,
}: ActionsConstructorParameters) {
assert(log, 'log is required')
assert(engine, 'engine is required')
assert(substitutionsVariables, 'substitutionsVariables is required')
if (buildConfiguration !== undefined) {
log.trace(`${Actions.name}()` + ` @${buildConfiguration.name}`)
} else {
log.trace(`${Actions.name}()`)
}
this.log = log
this.engine = engine
this.substitutionsVariables = substitutionsVariables
this.jsonActions = jsonActions ?? {}
if (buildConfiguration !== undefined) {
this.buildConfiguration = buildConfiguration
}
// If there are inherited actions, add them to the map.
// They might be overridden by the current definitions.
if (inheritedActionsMap !== undefined) {
for (const [
inheritedActionName,
inheritedAction,
] of inheritedActionsMap) {
// Make copies of the actions, do not alter the inherited ones.
const action = new Action({
actionName: inheritedActionName,
jsonAction: inheritedAction.jsonAction,
parentActions: this,
})
this._actionsMap.set(inheritedActionName, action)
}
}
// The rest of the initialisation is done in the async initialiser.
}
/**
* Completes the async initialisation of the actions collection.
*
* @remarks
* This method implements the first step of lazy evaluation. It processes
* all action definitions by expanding template action names based on matrix
* parameters, but does not evaluate the action content or perform Liquid
* substitutions. The actual template evaluation and variable substitution
* occur later when individual actions are initialised via
* {@link Action.initialise}, and only for actions that are
* actually used. This approach avoids unnecessary operations on unused
* actions. The method also validates that all expanded action names are
* unique.
*
* @returns A promise that resolves to `true` if initialisation was
* performed, or `false` if already initialised.
*
* @throws {@link ConfigurationError}
* If duplicate action names are detected or if template expansion fails.
*/
async initialise(): Promise {
const log = this.log
if (this._isInitialised) {
if (this.buildConfiguration !== undefined) {
log.trace(
`${Actions.name}.initialise()` +
` @${this.buildConfiguration.name} again`
)
} else {
log.trace(`${Actions.name}.initialise() again`)
}
return false
}
if (this.buildConfiguration !== undefined) {
log.trace(
`${Actions.name}.initialise()` + ` @${this.buildConfiguration.name}`
)
} else {
log.trace(`${Actions.name}.initialise()`)
}
for (const [actionName, jsonAction] of Object.entries(this.jsonActions)) {
if (hasLiquidSyntax(actionName)) {
await this._processTemplate({
actionName,
jsonActionTemplate: jsonAction as JsonActionTemplate,
})
} else {
if (this._namesSet.has(actionName)) {
throw new ConfigurationError(
`action name "${actionName}" already defined`
)
} else {
this._actionsMap.set(actionName, undefined)
this._jsonActionsNamesMap.set(actionName, actionName)
this._namesSet.add(actionName)
}
}
}
const names = Array.from(this._actionsMap.keys())
this._names = names
this.log.trace(`${Actions.name}.initialise() =>`, names)
this._isInitialised = true
return true
}
// --------------------------------------------------------------------------
// Public Methods.
/**
* The number of actions in the collection.
*
* @remarks
* This value is known only after `initialise()`.
*
* This getter provides direct access to the collection size, enabling
* callers to check for emptiness or iterate with knowledge of the
* collection's extent.
*
* @returns The number of actions in the collection.
*/
get size(): number {
assert(
this._isInitialised,
'Actions collection must be initialised before accessing size'
)
return this._actionsMap.size
}
/**
* Indicates whether the actions collection is empty.
*
* @remarks
* This value is known only after `initialise()`.
*
* @returns `true` if there are no actions, `false` otherwise.
*/
get isEmpty(): boolean {
assert(
this._isInitialised,
'Actions collection must be initialised before accessing isEmpty'
)
return this._actionsMap.size === 0
}
/**
* The names of all actions in the collection.
*
* @remarks
* This value is known only after `initialise()`.
*
* This getter returns the cached array of action names for efficient
* repeated access without recreating the array.
*
* @returns An array of action names.
*/
get names(): string[] {
assert(
this._isInitialised,
'Actions collection must be initialised before accessing names'
)
return this._names
}
/**
* Checks whether an action with the specified name exists.
*
* @remarks
* This value is known only after `initialise()`.
*
* @param actionName - The name of the action to check.
* @returns `true` if the action exists, `false` otherwise.
*/
has(actionName: string): boolean {
assert(
this._isInitialised,
'Actions collection must be initialised before accessing has()'
)
return this._actionsMap.has(actionName)
}
/**
* Retrieves an action by name, creating it if not yet instantiated.
*
* @remarks
* This method implements lazy evaluation to avoid unnecessary operations.
* Actions are instantiated on demand but remain uninitialised until actually
* used. The two-step process works as follows:
*
*
* - During collection initialisation
* (
Actions.initialise()),
* only the matrix of options is evaluated for each template, expanding
* only the action names without processing their content.
* - Later, when an action is accessed via this method and subsequently
* initialised (
Action.initialise()), the template is
* fully evaluated and Liquid substitutions are performed on the
* commands.
*
*
* This approach ensures that only actions that are actually used incur the
* cost of template evaluation and variable substitution.
*
* @param actionName - The name of the action to retrieve.
* @returns The action instance.
*
* @throws {@link ConfigurationError}
* If an action with that name does not exist.
*/
get(actionName: string): Action {
assert(
this._isInitialised,
'Actions collection must be initialised before accessing get()'
)
const log = this.log
log.trace(`${Actions.name}.get(${actionName})`)
let action = this._actionsMap.get(actionName)
if (action === undefined) {
const jsonActionName = this._jsonActionsNamesMap.get(actionName)
if (jsonActionName === undefined) {
throw new ConfigurationError(`action "${actionName}" does not exist`)
}
// Safety net: This fallback to empty string is defensive programming.
// The jsonActions[jsonActionName] should always be defined because
// _jsonActionsNamesMap is populated from the jsonActions keys during
// initialisation. The ?? '' provides protection against unexpected
// runtime inconsistencies between the map and the object.
/* c8 ignore start - safety net, action names are not undefined. */
const jsonAction: JsonActionContent = (this.jsonActions[jsonActionName] ??
'') as JsonActionContent
/* c8 ignore stop */
action = new Action({
actionName,
jsonAction,
parentActions: this,
})
this._actionsMap.set(actionName, action)
}
return action
}
// --------------------------------------------------------------------------
// Private Methods.
/**
* Processes a template action by expanding it and registering the generated
* actions.
*
* @remarks
* This helper method is called during collection initialisation for each
* action whose name contains template syntax (\{\{ markers).
*
* Processing steps:
*
*
* - Calls
_expandTemplateActions() to generate all action
* instances from the template's matrix parameters.
* - Validates that each expanded action name is unique and does not
* conflict with existing actions.
* - Registers each expanded action in the internal maps:
*
* _actionsMap: Maps name to action instance.
* _jsonActionsNamesMap: Maps expanded name back to
* original template name.
* _namesSet: Tracks all registered names for
* duplicate detection.
*
*
*
*
* @param actionName - The template action name containing Liquid variables.
* @param jsonActionTemplate - The JSON template definition containing matrix
* parameters and an action template.
* @returns A promise that resolves when processing is complete.
*
* @throws {@link ConfigurationError}
* If duplicate action names are detected during expansion or if template
* expansion fails.
*/
protected async _processTemplate({
actionName,
jsonActionTemplate,
}: {
actionName: string
jsonActionTemplate: JsonActionTemplate
}): Promise {
// Expand template and generate multiple actions.
try {
const expandedActionsMap = await this._expandTemplateActions({
actionName,
jsonActionTemplate,
})
for (const [expandedActionName, expandedAction] of expandedActionsMap) {
if (this._namesSet.has(expandedActionName)) {
throw new ConfigurationError(
`duplicate action name "${expandedActionName}" ` +
`could not be generated from template.`
)
} else {
this._actionsMap.set(expandedActionName, expandedAction)
this._jsonActionsNamesMap.set(expandedActionName, actionName)
this._namesSet.add(expandedActionName)
}
}
} catch (error) {
const message = getErrorMessage(error) + ` in action "${actionName}"`
throw new ConfigurationError(message)
}
}
/**
* Expands a template action into multiple concrete actions.
*
* @remarks
* This method uses the {@link TemplateExpander} to compute the Cartesian
* product of all matrix parameter values and creates a separate action for
* each combination, substituting matrix values into both the action name
* and command templates.
*
* Processing steps:
*
*
* - Validates matrix and template structure.
* - Delegates to
TemplateExpander for matrix processing and
* name expansion.
* - Creates action instances via factory callback for each
* combination.
*
*
* Matrix variables are scoped to individual actions and accessible via
* the `matrix` namespace during action command evaluation.
*
* @param actionName - The template action name containing Liquid variables.
* @param jsonActionTemplate - The JSON action template definition containing
* matrix parameters and a template.
* @returns A promise that resolves to a map of expanded action names to
* their corresponding action instances.
*
* @throws {@link ConfigurationError}
* If the matrix structure is invalid, template format is incorrect, or
* substitution fails.
*/
protected async _expandTemplateActions({
actionName,
jsonActionTemplate,
}: {
actionName: string
jsonActionTemplate: JsonActionTemplate
}): Promise