UNPKG

xpm

Version:

The xPack project manager command line tool

1,158 lines (1,070 loc) 39.2 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 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 <code>inheritedActionsMap</code> and * <code>buildConfiguration</code> 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<string, Action> /** * Optional build configuration this actions collection belongs to. */ buildConfiguration?: BuildConfiguration /** * The logger instance for output and diagnostics. */ log: Logger } /** * A collection of <b>xpm</b> 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: * * <ol> * <li><b>Construction:</b> Basic setup with optional inheritance from parent * package.</li> * <li><b>Initialisation:</b> Template name expansion without content * evaluation.</li> * <li><b>Retrieval:</b> On-demand instantiation when accessed via * <code>get()</code>.</li> * <li><b>Action Initialisation:</b> Liquid template evaluation and * substitution.</li> * </ol> * * 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: * * <ol> * <li><b>Base variables:</b> <code>env</code>, <code>os</code>, * <code>path</code> (always available).</li> * <li><b>Package variables:</b> <code>name</code>, <code>version</code>, * <code>dependencies</code>, * <code>devDependencies</code>.</li> * <li><b>Configuration variables:</b> build folder paths, compiler * settings.</li> * <li><b>Properties:</b> custom key-value pairs from package or * configuration.</li> * <li><b>Matrix:</b> parameter combinations for template-generated * actions (added per action during initialisation).</li> * </ol> * * 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: * * <ol> * <li><b>Simple strings:</b> Single command to execute.</li> * <li><b>String arrays:</b> Multiple commands executed sequentially.</li> * <li><b>Template objects:</b> With <code>matrix</code> and * <code>template</code> properties for * generating multiple actions from a single definition.</li> * </ol> * * 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: * * <ol> * <li>Actions inherit configuration-specific variables (build folder paths, * compiler settings, toolchain properties).</li> * <li>Actions belong to a specific configuration namespace rather than the * package root.</li> * <li>Logging and diagnostics include the configuration name for * context.</li> * </ol> * * When `undefined`: * * <ol> * <li>Actions belong to the package root (<code>xpack.actions</code> in * <code>package.json</code>).</li> * <li>Only package-level and global variables are available for * substitution.</li> * </ol> */ 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: * * <ol> * <li>Known only after <code>Actions.initialise()</code> * completes.</li> * <li>Possibly empty if there are no actions defined.</li> * <li>Values can be <code>undefined</code> to indicate an action * exists but hasn't * been instantiated yet (lazy loading).</li> * <li>For template actions, contains one entry per expanded combination, * not the original template definition.</li> * </ol> * * Actions transition from `undefined` to instantiated when first accessed * via {@link Actions.get}, implementing the lazy evaluation * pattern. */ protected readonly _actionsMap: Map<string, Action | undefined> = 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: * * <ol> * <li>Known only after <code>Actions.initialise()</code> * completes.</li> * <li>Contains all action names including those generated from * templates.</li> * <li>Used to detect duplicate action names that might arise from template * expansion conflicts or explicit duplicates in * <code>package.json</code>.</li> * </ol> * * 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<string> = new Set<string>() /** * 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: * * <ol> * <li><b>For regular actions:</b> Maps action name to itself (identity * mapping).</li> * <li><b>For template actions:</b> Maps each generated action name back to * the original template name (e.g., * <code>test-x64</code> → <code>test-\{\{ matrix.arch \}\}</code>).</li> * <li>Enables <code>Actions.get()</code> to locate the correct JSON * definition when instantiating an action on demand.</li> * </ol> * * This indirection is essential for the lazy evaluation pattern, allowing * deferred instantiation while maintaining the connection to original * definitions. */ protected readonly _jsonActionsNamesMap: Map<string, string> = 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: * * <ol> * <li>Initially <code>false</code> after construction.</li> * <li>Set to <code>true</code> after successful template expansion and * action name * registration.</li> * <li>Checked at the beginning of <code>Actions.initialise()</code> to * return early if already initialised.</li> * </ol> * * 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: * * <ol> * <li>Empty initially after construction.</li> * <li>Populated during <code>Actions.initialise()</code> after all * action names * are determined.</li> * <li>Contains all action names including those generated from * templates.</li> * <li>Returned by the <code>names</code> getter for efficient repeated * access.</li> * </ol> * * 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<boolean> { 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: * * <ol> * <li>During collection initialisation * (<code>Actions.initialise()</code>), * only the matrix of options is evaluated for each template, expanding * only the action names without processing their content.</li> * <li>Later, when an action is accessed via this method and subsequently * initialised (<code>Action.initialise()</code>), the template is * fully evaluated and Liquid substitutions are performed on the * commands.</li> * </ol> * * 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 (<code>\{\{</code> markers). * * Processing steps: * * <ol> * <li>Calls <code>_expandTemplateActions()</code> to generate all action * instances from the template's matrix parameters.</li> * <li>Validates that each expanded action name is unique and does not * conflict with existing actions.</li> * <li>Registers each expanded action in the internal maps: * <ul> * <li><code>_actionsMap</code>: Maps name to action instance.</li> * <li><code>_jsonActionsNamesMap</code>: Maps expanded name back to * original template name.</li> * <li><code>_namesSet</code>: Tracks all registered names for * duplicate detection.</li> * </ul> * </li> * </ol> * * @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<void> { // 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: * * <ol> * <li>Validates matrix and template structure.</li> * <li>Delegates to <code>TemplateExpander</code> for matrix processing and * name expansion.</li> * <li>Creates action instances via factory callback for each * combination.</li> * </ol> * * 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<Map<string, Action>> { const log = this.log log.trace(`${Actions.name}.#expandTemplateActions(${actionName})`) // Validate template structure // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (jsonActionTemplate.matrix == undefined) { throw new ConfigurationError(`action "${actionName}" has no matrix`) } if (!isJsonObject(jsonActionTemplate.matrix)) { throw new ConfigurationError( `action "${actionName}" matrix is not an object` ) } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (jsonActionTemplate.template == undefined) { throw new ConfigurationError(`action "${actionName}" has no template`) } if ( !isString(jsonActionTemplate.template) && !isJsonArray(jsonActionTemplate.template) ) { throw new ConfigurationError( `action "${actionName}" template is not a string or array` ) } // Use TemplateExpander for matrix processing and expansion const expander = new TemplateExpander<JsonActionContent, Action>({ engine: this.engine, substitutionsVariables: this.substitutionsVariables, log: this.log, }) return await expander.expandTemplate({ templateName: actionName, matrix: jsonActionTemplate.matrix, templateContent: jsonActionTemplate.template, templateType: 'action', instanceFactory: ( expandedName: string, combination: Record<string, string>, templateContent: JsonActionContent ) => new Action({ actionName: expandedName, jsonAction: templateContent, parentActions: this, matrixParameters: { ...combination }, }), }) } } // ============================================================================ /** * Configuration parameters for constructing an action instance. * * @remarks * This interface defines the required configuration for creating an * instance of {@link Action}. Most properties are mandatory except for * the optional <code>matrixParameters</code>, which is only needed for * template-generated actions that were created from matrix expansion. * * The parameters provide the action with its identity (name), command * definitions, access to the parent collection for shared resources, and * optional matrix parameter values for template-generated actions. */ export interface ActionConstructorParameters { /** * The name of the action. */ actionName: string /** * The JSON definition of the action commands. */ jsonAction: JsonActionContent /** * The parent actions collection this action belongs to. */ parentActions: Actions /** * Optional matrix parameter values for template-generated actions. */ matrixParameters?: LiquidSubstitutionsStrings } /** * An individual <b>xpm</b> action containing commands to be executed. * * @remarks * Actions are lazily initialised, with variable substitution performed * only when the action is first retrieved and initialised. This allows for * efficient handling of large numbers of actions generated * from templates. * * An action can exist in three states: * * <ol> * <li><b>Undefined:</b> Name is known but instance not yet created.</li> * <li><b>Instantiated:</b> Object exists but commands not yet evaluated.</li> * <li><b>Initialised:</b> Commands fully evaluated with Liquid * substitutions.</li> * </ol> * * This design minimizes memory usage and computation for actions that are * defined but never executed, which is common when using matrix templates * to generate platform-specific or configuration-specific actions. */ export class Action { // -------------------------------------------------------------------------- // Public Members. /** * The name of the action. * * @remarks * This is the final, expanded action name used for identification and * execution. For template-generated actions, this is the concrete name * after matrix substitution (e.g., `test-x64` rather than * `test-{{ matrix.arch }}`). * * The name is used for: * * <ol> * <li>User-facing identification when listing or executing actions.</li> * <li>Logging and diagnostic output to track action lifecycle.</li> * <li>Creating copies of inherited actions with preserved names.</li> * </ol> * * Names must be unique within the actions collection, enforced during * {@link Actions.initialise}. */ readonly name: string /** * The JSON definition of the action commands. * * @remarks * This holds the raw command definition as it appears in `package.json`, * before variable substitution. The format can be: * * <ol> * <li><b>Simple string:</b> Single command line.</li> * <li><b>String array:</b> Multiple commands for sequential execution.</li> * </ol> * * The definition is preserved in its original form to enable: * * <ol> * <li>Creating copies of inherited actions with identical definitions.</li> * <li>Deferred template evaluation during * <code>Action.initialise()</code>.</li> * <li>Re-evaluation if needed with different variable contexts.</li> * </ol> * * This immutable storage ensures actions can be safely copied and * initialised multiple times without side effects. */ readonly jsonAction: JsonActionContent /** * The parent actions collection this action belongs to. * * @remarks * This reference maintains the hierarchical relationship between individual * actions and their containing collection, providing essential context for * action initialisation and execution. * * The parent collection provides access to: * * <ol> * <li>Liquid templating engine for variable substitution.</li> * <li>Substitution variables hierarchy (package metadata, configuration, * environment, platform detection).</li> * <li>Logger instance for diagnostic output.</li> * <li>Build configuration context when actions belong to a specific * configuration rather than the package root.</li> * </ol> * * This design enables actions to access shared resources without duplicating * them, while maintaining proper scoping for template evaluation. During * initialisation, the action combines parent-level substitution variables * with its own matrix parameters to create a complete context for Liquid * template processing. */ readonly parentActions: Actions /** * The matrix parameter values for template-generated actions. * * @remarks * For template-generated actions, this object contains the specific matrix * parameter values that produced this action instance from the template. * * Usage pattern: * * <ol> * <li>Undefined for regular (non-template) actions.</li> * <li>For template actions, contains key-value pairs from the matrix * combination (e.g., * <code>\{ arch: 'x64', platform: 'linux' \}</code>).</li> * <li>Merged into substitution variables during * <code>Action.initialise()</code>, making values accessible via the * <code>matrix</code> namespace in command templates.</li> * <li>Enables the same command template to generate different concrete * commands for each matrix combination.</li> * </ol> * * Example: A template with `{{ matrix.arch }}` becomes `x64` when this * action's matrix parameters include `{ arch: 'x64' }`. */ protected readonly _matrixParameters?: LiquidSubstitutionsStrings /** * The array of command strings after variable substitution. * * @remarks * This array contains the fully evaluated command lines ready for * execution, with all Liquid template variables substituted. * * Lifecycle states: * * <ol> * <li>Undefined initially and until <code>Action.initialise()</code> * is called.</li> * <li>Populated during initialisation by evaluating * <code>jsonAction</code> with the * Liquid engine and complete variable context.</li> * <li>Array-based JSON definitions are joined, substituted, then split back * into individual command lines.</li> * <li>Each string represents one command line to be executed * sequentially.</li> * </ol> * * Attempting to access via the `commands` getter before initialisation * will trigger an assertion error, enforcing correct usage order. */ protected _commands?: string[] /** * Flag indicating whether the action has been initialised. * * @remarks * This flag ensures idempotent initialization and prevents redundant * template evaluation when {@link Action.initialise} is called * multiple times. * * State transitions: * * <ol> * <li>Initially <code>false</code> after construction.</li> * <li>Set to <code>true</code> after successful command substitution and * evaluation.</li> * <li>Checked at the start of <code>Action.initialise()</code> to * return early if already initialised.</li> * </ol> * * This pattern allows safe repeated calls during complex initialization * sequences or when actions are accessed multiple times, avoiding the * computational cost of re-evaluating templates unnecessarily. */ protected _isInitialised = false // -------------------------------------------------------------------------- // Constructor and async initialiser. /** * Constructs an action instance. * * @remarks * The constructor performs partial initialisation. Variable substitution * requires calling the {@link Action.initialise} method. * * @param actionName - The name of the action. * @param jsonAction - The JSON definition of the action commands. * @param parentActions - The parent actions collection this action belongs * to. * @param matrixParameters - Optional matrix parameter values for * template-generated actions. */ constructor({ actionName, jsonAction, parentActions, matrixParameters, }: ActionConstructorParameters) { assert(actionName, 'actionName is required') // assert(jsonAction) // Can be an empty string. assert(parentActions, 'parentActions is required') const log = parentActions.log log.trace(`${Action.name}(${actionName})`) this.name = actionName this.jsonAction = jsonAction this.parentActions = parentActions if (matrixParameters !== undefined) { this._matrixParameters = matrixParameters } } /** * Completes the async initialisation of the action. * * @remarks * This method performs variable substitution on the action commands using * the Liquid templating engine and the available substitution variables, * including any matrix parameters for template-generated actions. * * The substitution context includes: * * <ol> * <li>All package-level substitution variables (configuration, package * metadata, platform detection, etc.).</li> * <li>Build configuration variables if this action belongs to a * configuration.</li> * <li>Matrix parameters for template-generated actions, accessible via * the <code>matrix</code> namespace (e.g., * <code>\{\{ matrix.arch \}\}</code>).</li> * </ol> * * Array-based command definitions are joined with newlines before * substitution, then split back into individual commands after processing. * This allows commands to span multiple array elements while maintaining * clean formatting in the package manifest. * * @returns A promise that resolves to `true` if initialisation was * performed, or `false` if already initialised. * * @throws {@link ConfigurationError} * If command substitution fails. */ async initialise(): Promise<boolean> { const log = this.parentActions.log if (this._isInitialised) { log.trace(`${Action.name}.initialise(${this.name}) again`) return false } log.trace(`${Action.name}.initialise(${this.name})`) // Silently accept empty or non-existing actions. const jsonAction = this.jsonAction const inputCommands = Array.isArray(jsonAction) ? jsonAction.join(os.EOL) : jsonAction let substitutedCommands if (hasLiquidSyntax(inputCommands)) { try { substitutedCommands = await performSubstitutions({ input: inputCommands, engine: this.parentActions.engine, substitutionsVariables: { ...this.parentActions.substitutionsVariables, matrix: this._matrixParameters ?? {}, }, log, }) } catch (error) { const message = getErrorMessage(error) + ` in action "${this.name}" commands substitution` throw new ConfigurationError(message) } } else { substitutedCommands = inputCommands } this._commands = substitutedCommands .replace(new RegExp(os.EOL + '$'), '') .split(os.EOL) log.trace(`${Action.name}.initialise() =>`, this.name) log.trace(this.name, 'commands =>', this._commands) this._isInitialised = true return true } // -------------------------------------------------------------------------- // Public Methods. /** * Retrieves the array of command strings for this action. * * @remarks * The action must be initialised via {@link Action.initialise} * before accessing this property. Attempting to access commands from an * uninitialised action will result in an assertion error. * * @returns The array of command strings after variable substitution. */ get commands(): string[] { assert( this._isInitialised, 'Action must be initialised before accessing commands' ) assert(this._commands, 'Action _commands not initialised') return this._commands } } // ----------------------------------------------------------------------------