PwSh.Fw.GenericPackageEngine.psm1

#requires -version 7.0

$Script:ModuleRoot = $PSScriptRoot

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 1 : RESOLVE
# Resolve a product definition for one given target
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Resolve a product pkgdef for one target.
 
.DESCRIPTION
Loads a pkgdef v2 definition (from a file or a hashtable) and resolves it for
one target :
  - download : key-by-key merge of the global block and the target block
                (target values win) — the only merge of the schema ;
  - install / post : replacement (target block wins, no concatenation) ;
  - version : target value wins over the global value ;
  - payload, upload : taken from the target when present ;
  - kb4 / anypkg placeholders : ignored by the engine but preserved as-is,
                both at root level and at target level.
 
The target is selected either explicitly (-TargetId) or by platform match
(-Os / -Distro / -Arch). Platform match rules :
  - os : exact match ;
  - arch: exact match, or target.arch is 'all', or no arch requested ;
  - distro: exact match (an empty requested distro only matches targets
            without a distro) ;
  - first match wins.
 
.EXAMPLE
$resolved = Resolve-GenericPackageDefinition -Path ./examples/firefox.yml -TargetId windows-x64
 
.EXAMPLE
$resolved = Resolve-GenericPackageDefinition -Path ./examples/libreoffice.yml -Os linux -Distro debian -Arch x64
#>

function Resolve-GenericPackageDefinition {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable, e.g. loaded yaml)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Explicit target id (e.g. windows-x64)
        [Parameter(Mandatory = $false)][string]$TargetId,
        # Target OS: 'windows', 'linux', 'macos'
        [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os,
        # Target distribution (linux)
        [Parameter(Mandatory = $false)][string]$Distro = '',
        # Target architecture
        [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = ''
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) {
            Write-Fatal "Either -Path or -Definition must be provided"
        }
        if ($Path -and $Definition) {
            Write-Fatal "Provide either -Path or -Definition, not both"
        }
        if ($TargetId -and $Os) {
            Write-Fatal "Provide either -TargetId or -Os, not both"
        }
        if (-not $TargetId -and -not $Os) {
            Write-Fatal "A target must be selected : -TargetId or -Os (plus -Distro/-Arch)"
        }

        # ─── Load raw definition ──────────────────────────────────────────────
        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        if (-not $raw -or -not $raw.name) {
            Write-Fatal "Invalid pkgdef : 'name' is missing"
        }

        $targets = @($raw.targets)
        if ($targets.Count -eq 0) {
            Write-Fatal "No targets defined in pkgdef '$($raw.name)'"
        }

        # ─── Select target ────────────────────────────────────────────────────
        if ($TargetId) {
            $target = @($targets | Where-Object { $_.id -eq $TargetId }) | Select-Object -First 1
            if (-not $target) {
                Write-Fatal "Target '$TargetId' not found in pkgdef '$($raw.name)' (available : $(($targets | ForEach-Object { $_.id }) -join ', '))"
            }
        } else {
            $target = @($targets | Where-Object {
                $_.os -eq $Os.ToLower() `
                -and ($_.arch -eq $Arch -or $_.arch -eq 'all' -or [string]::IsNullOrEmpty($Arch)) `
                -and (([string]::IsNullOrEmpty($Distro) -and [string]::IsNullOrEmpty($_.distro)) -or ($_.distro -eq $Distro.ToLower()))
            }) | Select-Object -First 1
            if (-not $target) {
                Write-Fatal "No target matching os=$Os distro=$Distro arch=$Arch in pkgdef '$($raw.name)'"
            }
        }

        # ─── Build resolved definition ────────────────────────────────────────
        $resolved = @{
            name     = $raw.name
            codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-'))
            type     = 'product'
            version  = ($target.version ?? $raw.version) ?? 'latest'
            depends  = @($raw.depends)
            tags     = @($raw.tags)
            download = Merge-GenericPackageDownload -Global $raw.download -Override $target.download
            install  = @($target.install ?? $raw.install)
            post     = @($target.post ?? $raw.post)
            target   = @{
                id     = $target.id
                os     = $target.os
                distro = $target.distro ?? ''
                arch   = $target.arch
            }
        }
        if ($target.payload) { $resolved.payload = $target.payload }
        if ($target.upload) { $resolved.upload = $target.upload }

        # Placeholders : preserved as-is (root and target level)
        foreach ($ph in @('kb4', 'anypkg')) {
            if ($raw.$ph) { $resolved.$ph = $raw.$ph }
            if ($target.$ph) { $resolved.target.$ph = $target.$ph }
        }

        Write-Devel "Resolved '$($raw.name)' for target '$($target.id)' (os=$($target.os) distro=$($target.distro ?? '') arch=$($target.arch) version=$($resolved.version))"
        return $resolved
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# EXPAND : one resolved file per target (CI matrix)
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Expand a pkgdef into one resolved definition file per target.
 
.DESCRIPTION
Resolves every target of the product and writes one YAML file per target,
named '<codename>-<target.id>.yml', into -OutputDir. Returns the list of
written file paths. Useful to feed a CI matrix.
 
.EXAMPLE
$files = Expand-GenericPackageTargets -Path ./examples/firefox.yml -OutputDir ./resolved
#>

function Expand-GenericPackageTargets {
    [CmdletBinding()]
    [OutputType([array])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Output directory for the resolved files
        [Parameter(Mandatory = $true)][string]$OutputDir,
        # Overwrite existing files
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        if (-not (Test-DirExist $OutputDir)) {
            $null = New-Item -Path $OutputDir -ItemType Directory -Force
        }

        $files = @()
        foreach ($target in @($raw.targets)) {
            $resolved = Resolve-GenericPackageDefinition -Definition $raw -TargetId $target.id
            $fileName = "$($resolved.codename)-$($target.id).yml"
            $filePath = Join-Path $OutputDir $fileName
            # NB : parenthesize the command call — without them PowerShell
            # binds '-and' as a parameter of Test-FileExist
            if ((Test-FileExist $filePath) -and (-not $Force)) {
                Write-Warning "File exists, skipped (use -Force to overwrite) : $filePath"
                continue
            }
            $resolved | ConvertTo-Yaml -Options WithIndentedSequences | Set-Content -Path $filePath -Encoding UTF8
            Write-Verbose "Wrote $filePath"
            $files += $filePath
        }
        return $files
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# VALIDATE : semantic checks on top of the JSON Schema
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Semantic validation of a pkgdef.
 
.DESCRIPTION
Complements the static JSON Schema validation with semantic checks :
  - uniqueness of target ids ;
  - validity of asset / signature-file regex patterns ;
  - provider coherence (owner/repo, url, artifact) ;
  - declared payload vs asset pattern extension mismatch (warning) ;
  - upload coherence (nexus requires repository).
 
Returns a results object : [PSCustomObject]@{ Valid ; Errors ; Warnings }.
 
.EXAMPLE
$result = Test-GenericPackageDefinition -Path ./examples/firefox.yml
if (-not $result.Valid) { $result.Errors }
#>

function Test-GenericPackageDefinition {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition
    )
    Begin {
        Write-EnterFunction
        $errors = @()
        $warnings = @()
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        if ($Path) {
            if (-not (Test-FileExist $Path)) {
                return [PSCustomObject]@{ Valid = $false; Errors = @("File not found : $Path"); Warnings = @() }
            }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }
        if (-not $raw -or -not $raw.name) {
            return [PSCustomObject]@{ Valid = $false; Errors = @("'name' is missing") ; Warnings = @() }
        }

        $targets = @($raw.targets)
        if ($targets.Count -eq 0) { $errors += "product requires at least one target" }

        # ─── target id uniqueness ─────────────────────────────────────────────
        $ids = @($targets | ForEach-Object { $_.id })
        foreach ($dup in ($ids | Group-Object | Where-Object { $_.Count -gt 1 })) {
            $errors += "duplicate target id : $($dup.Name)"
        }

        # ─── per-target checks ────────────────────────────────────────────────
        foreach ($target in $targets) {
            # merged download view (same rule as Resolve)
            $download = @{}
            if ($raw.download) { foreach ($k in $raw.download.Keys) { $download[$k] = $raw.download[$k] } }
            if ($target.download) { foreach ($k in $target.download.Keys) { $download[$k] = $target.download[$k] } }

            # provider coherence
            $provider = $download.provider
            if ($provider) {
                switch ($provider) {
                    { $_ -in @('github', 'gitlab', 'gitlab-artifact') } {
                        if (-not $download.owner) { $errors += "[$($target.id)] provider $provider requires 'owner'" }
                        if (-not $download.repo) { $errors += "[$($target.id)] provider $provider requires 'repo'" }
                    }
                    { $_ -in @('url', 'webscrapper') } {
                        if (-not $download.url) { $errors += "[$($target.id)] provider $provider requires 'url'" }
                    }
                    'gitlab-artifact' {
                        if (-not $download.artifact) { $errors += "[$($target.id)] provider gitlab-artifact requires 'artifact'" }
                        elseif (-not $download.artifact.ref -or -not $download.artifact.job) {
                            $errors += "[$($target.id)] artifact requires 'ref' and 'job'"
                        }
                    }
                }
            }

            # regex validity + payload/pattern heuristic
            foreach ($asset in @($download.assets)) {
                foreach ($pattern in @($asset.pattern, $asset.'signature-file'.pattern)) {
                    if (-not $pattern) { continue }
                    $clean = $pattern -replace '\{\{.*?\}\}', ''
                    try {
                        $null = [regex]::new($clean)
                    } catch {
                        $errors += "[$($target.id)] invalid regex pattern : $pattern"
                    }
                }
                if ($target.payload -and $asset.pattern) {
                    $suffix = switch ($target.payload) {
                        'tar.gz' { '\.tar\.gz' }
                        default { "\.$($target.payload)" }
                    }
                    $literal = $asset.pattern -replace '\{\{.*?\}\}', ''
                    $extMatch = [regex]::Match($literal, '\\\.(?<ext>[a-zA-Z0-9.]+)$')
                    if ($extMatch.Success) {
                        $declaredExt = $extMatch.Groups['ext'].Value.ToLower()
                        $expectedExt = ($suffix -replace '^\\\.','')
                        if ($declaredExt -ne $expectedExt -and $declaredExt -ne $expectedExt.Split('.')[0]) {
                            $warnings += "[$($target.id)] payload '$($target.payload)' does not look like the pattern extension '$declaredExt'"
                        }
                    }
                }
            }

            # upload coherence
            if ($target.upload) {
                if ($target.upload.type -eq 'nexus' -and -not $target.upload.repository) {
                    $errors += "[$($target.id)] upload type 'nexus' requires 'repository'"
                }
                if (-not $target.upload.url) {
                    $errors += "[$($target.id)] upload requires 'url'"
                }
            }
        }

        $valid = -not $errors.Count
        foreach ($w in $warnings) { Write-Warning $w }
        return [PSCustomObject]@{ Valid = $valid; Errors = $errors; Warnings = $warnings }
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 2 : DOWNLOAD
# Download the asset of a resolved definition
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Download the asset of a resolved package definition.
 
.DESCRIPTION
Dispatches to the provider implementation (github, gitlab, gitlab-artifact,
url, webscrapper) using the resolved download block. The API token is read
from the environment variable named by 'token_env' (never stored in the
definition). The asset is cached : if the file already exists in the cache
directory, the download is skipped. When the matched asset declares a
signature-file, the companion checksum file is downloaded from the same
source and the asset is verified before being returned.
 
Returns the local path of the downloaded asset.
 
.EXAMPLE
$asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath '/tmp/cache'
#>

function Download-GenericPackageAsset {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Download is the intended verb for this function.")]
    [CmdletBinding()]
    [OutputType([String])]
    Param (
        # Resolved package definition (output of Resolve-GenericPackageDefinition)
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef,
        # Path to the cache directory to download assets to
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $pkgdef.download) {
            Write-Warning "No download configuration found"
            return $null
        }

        # Resolve CachePath: parameter > pkgdef.download.destination > temp path
        if ([string]::IsNullOrEmpty($CachePath)) {
            if ($pkgdef.download.destination) {
                $CachePath = $pkgdef.download.destination
            } else {
                $CachePath = [io.path]::GetTempPath()
            }
        }
        if (!(Test-Path -Path $CachePath)) {
            $null = New-Item -Path $CachePath -ItemType Directory -Force
        }

        $token = Get-GenericPackageToken -pkgdef $pkgdef

        # Seed mustache values from the resolved definition
        # (caller-provided values win over the derived ones)
        $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues

        Write-Verbose "Downloading asset using provider '$($pkgdef.download.provider)'"
        Write-Devel -InputObject $pkgdef.download

        switch ($pkgdef.download.provider) {
            'github' {
                $asset = Download-AssetFromGithub -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            'gitlab' {
                $asset = Download-AssetFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'gitlab-artifact' {
                $asset = Download-ArtifactFromGitlab -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'url' {
                $asset = Download-AssetFromUrl -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            'webscrapper' {
                $asset = Download-AssetFromWebScrapper -pkgdef $pkgdef -DestinationPath $CachePath -MustacheValues $mv
            }
            default {
                Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'"
                return $null
            }
        }

        if ($asset -and (Test-FileExist $asset)) {
            return $asset
        } else {
            Write-Fatal "Failed to download asset"
            return $null
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Get the asset information of a resolved package definition without downloading.
 
.DESCRIPTION
Inspects the download configuration and resolves which asset would be
downloaded, returning metadata instead of performing actual downloads :
  - name : asset file name
  - download_url : URL to download from
  - local_path : expected local file path (cache / file name)
  - digest : hash digest when available (github)
 
.EXAMPLE
$assets = Get-GenericPackageAssetInfo -pkgdef $resolved -CachePath '/tmp/cache'
#>

function Get-GenericPackageAssetInfo {
    [CmdletBinding()]
    [OutputType([array])]
    Param (
        # Resolved package definition
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$pkgdef,
        # Cache directory (used to compute local_path)
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $pkgdef.download) {
            Write-Warning "No download configuration found"
            return @()
        }
        if ([string]::IsNullOrEmpty($CachePath)) {
            $CachePath = $pkgdef.download.destination ?? [io.path]::GetTempPath()
        }

        $token = Get-GenericPackageToken -pkgdef $pkgdef

        # Seed mustache values from the resolved definition
        # (caller-provided values win over the derived ones)
        $mv = Get-GenericPackageMustacheValues -pkgdef $pkgdef -overrides $mustacheValues

        switch ($pkgdef.download.provider) {
            'github' {
                $assets = Get-AssetInfoFromGithub -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            'gitlab' {
                $assets = Get-AssetInfoFromGitlab -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com") -GitLabToken $token
            }
            'gitlab-artifact' {
                $assets = Get-AssetInfoFromGitlabArtifact -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv -GitLabBaseUrl ($pkgdef.download.gitlab_url ?? "https://gitlab.com")
            }
            'url' {
                $assets = Get-AssetInfoFromUrl -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            'webscrapper' {
                $assets = Get-AssetInfoFromWebScrapper -pkgdef $pkgdef -CachePath $CachePath -MustacheValues $mv
            }
            default {
                Write-Fatal "Unknown download provider: '$($pkgdef.download.provider)'"
                return @()
            }
        }

        # Normalize : always return one array object. The unary comma keeps
        # the array intact on the output pipeline (otherwise a single-element
        # result would be unwrapped into a bare hashtable by the caller)
        if (-not $assets) {
            return , @()
        }
        return , @($assets)
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 3 / 4 : INSTALL + POST
# Execute the install steps, then the post-install steps
# ═══════════════════════════════════════════════════════════════════════════════

function Execute-GenericPackageSteps {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Label of the phase (install / post) — for logging only
        [Parameter(Mandatory = $true)][string]$Phase,
        # Steps to execute (may be empty or contain null entries : both are
        # skipped. NB : cannot be Mandatory [object[]] — PowerShell refuses
        # to bind empty or null-containing arrays to mandatory array params)
        [Parameter(Mandatory = $false)][object[]]$Steps,
        # Destination path (chroot / mount / install root)
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Steps -or $Steps.Count -eq 0) {
            Write-Verbose "No $Phase steps defined"
            return $true
        }

        foreach ($step in $Steps) {
            if ($null -eq $step) { continue }
            if ($step -eq @{}) { continue }
            Write-Verbose "Executing $Phase step '$($step.action)'"
            Write-Devel -InputObject $step
            $rc = Execute-GenericPackageStep -step $step -DestinationPath $DestinationPath -mustacheValues $mustacheValues
            if (-not $rc) {
                Write-Warning "$Phase step '$($step.action)' failed"
                return $false
            }
        }
        return $true
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Execute one installation step.
 
.DESCRIPTION
Internal step executor shared by the install and post phases. Supported
actions : apt, copy, delete, exec, exec-chroot, exec-scriptblock, file,
mkdir, rename, unzip. All paths are relative to -DestinationPath. Mustache
templating is applied on text fields.
#>

function Execute-GenericPackageStep {
    [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseApprovedVerbs", "", Justification="Execute is an intuitive verb for this function. We could use Invoke but the meaning is not that.")]
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Step definition from yaml
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$step,
        # Destination path to install : typically a LinuxPE chroot or a WinPE mount path
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Template values : single level, uppercased keys hashtable suitable for mustache {{ VALUE }}
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        Write-Devel -InputObject $mustacheValues
        $rc = $false
        switch ($step.action) {
            'apt' {
                $script = @"
EOF
export DEBIAN_FRONTEND=noninteractive
apt-get update
[ `$? -eq 0 ] || exit `$?
 
apt-get install --no-install-recommends -y $($step.packages -join ' ')
[ `$? -eq 0 ] || exit `$?
 
EOF
"@

                $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool
            }
            'copy' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                $destination = Render-MustacheField -value $step.destination -mustacheValues $mustacheValues
                $destFile = "$DestinationPath/$destination"
                $parent = Split-Path $destFile -Parent
                if ($parent -and -not (Test-DirExist $parent)) {
                    $null = New-Item -Path $parent -ItemType Directory -Force
                }
                $null = Copy-Item -Path "$DestinationPath/$source" -Destination $destFile -Force
                $rc = Test-Path $destFile -PathType Leaf
            }
            'delete' {
                $path = Render-MustacheField -value $step.path -mustacheValues $mustacheValues
                $target = "$DestinationPath/$($path.Trim('/'))"
                if (Test-Path $target) {
                    Remove-Item -Path $target -Recurse -Force
                }
                $rc = -not (Test-Path $target)
            }
            'exec' {
                # 2 cases :
                # - user provides cmd: attribute with whole command
                # - user provides exe: and args: as separate attributes
                if ($step.cmd) {
                    $command = Render-MustacheField -value $step.cmd -mustacheValues $mustacheValues
                    $workingDir = if ($step.workingDir) { "$DestinationPath/$($step.workingDir)" } else { $DestinationPath }
                    $rc = Execute-Command $command -WorkingDir $workingDir -AsBool
                } else {
                    $exe = Render-MustacheField -value $step.exe -mustacheValues $mustacheValues
                    $parameters = Render-MustacheField -value $step.args -mustacheValues $mustacheValues
                    $workingDir = if ($step.workingDir) { "$DestinationPath/$($step.workingDir)" } else { $DestinationPath }
                    $rc = Execute-Command -exe $exe -args $parameters -WorkingDir $workingDir -AsBool
                }
            }
            'exec-chroot' {
                $script = Render-MustacheField -value $step.script -mustacheValues $mustacheValues
                $script = @"
EOF
$script
EOF
"@

                $rc = Execute-Command -exe "/bin/bash" "-c `"LANG=C.UTF-8 chroot $DestinationPath <<- $script`"" -AsBool
            }
            'exec-scriptblock' {
                $scriptblock = Render-MustacheField -value $step.scriptblock -mustacheValues $mustacheValues
                $output = Invoke-Command -ScriptBlock ([scriptblock]::Create($scriptblock))
                if ($step.variable) { $mustacheValues[$($step.variable)] = $output.ToString() }
                $rc = $true
            }
            'file' {
                $filepath = Render-MustacheField -value $step.filepath -mustacheValues $mustacheValues
                $content = Render-MustacheField -value $step.content -mustacheValues $mustacheValues
                $parent = Split-Path "$DestinationPath/$filepath" -Parent
                if ($parent -and -not (Test-DirExist $parent)) {
                    $null = New-Item -Path $parent -ItemType Directory -Force
                }
                $null = Set-Content -Path "$DestinationPath/$filepath" -Value $content
                $rc = Test-Path "$DestinationPath/$filepath" -PathType Leaf
            }
            'mkdir' {
                $null = New-Item -Path "$DestinationPath/$($step.path.Trim('/'))" -ItemType Directory -ErrorAction SilentlyContinue
                $rc = Test-Path "$DestinationPath/$($step.path.Trim('/'))" -PathType Container
            }
            'rename' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                $newName = Render-MustacheField -value $step.newname -mustacheValues $mustacheValues
                $srcFile = "$DestinationPath/$source"
                $parentDir = Split-Path $srcFile -Parent
                $null = Rename-Item -Path $srcFile -NewName $newName
                $rc = Test-Path "$parentDir/$newName"
            }
            'unzip' {
                $source = Render-MustacheField -value $step.source -mustacheValues $mustacheValues
                $null = New-Item -Path "$DestinationPath/$($step.destination)" -ItemType Directory -ErrorAction SilentlyContinue
                $null = Expand-Archive -Path $source -DestinationPath "$DestinationPath/$($step.destination)"
                $rc = Test-Path "$DestinationPath/$($step.destination)" -PathType Container
            }
            default {
                Write-Fatal "Unknown step action : '$($step.action)'"
            }
        }
        return $rc
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PHASE 5 : UPLOAD
# Publish the downloaded asset to a server
# ═══════════════════════════════════════════════════════════════════════════════

function Invoke-GenericPackageUpload {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        # Upload block (target.upload of the resolved definition)
        [Parameter(Mandatory = $true)][hashtable]$upload,
        # Local file to upload
        [Parameter(Mandatory = $true)][string]$Source,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $url = Render-MustacheField -value $upload.url -mustacheValues $mustacheValues
        $filename = $null
        if ($upload.filename) {
            $filename = Render-MustacheField -value $upload.filename -mustacheValues $mustacheValues
        }

        $username = $upload.username
        $password = ''
        if ($upload.password_env) {
            $password = [Environment]::GetEnvironmentVariable($upload.password_env)
            if ([string]::IsNullOrEmpty($password)) {
                Write-Fatal "Environment variable '$($upload.password_env)' is empty or not set"
            }
        }
        $headers = @{}
        if ($username) {
            $auth = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${username}:${password}"))
            $headers['Authorization'] = "Basic $auth"
        }

        switch ($upload.type) {
            'dufs' {
                $targetUrl = $url
                if ($filename) { $targetUrl = "$($url.TrimEnd('/'))/$filename" }
                Write-Verbose "Uploading $Source to $targetUrl (dufs/WebDAV)"
                $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing
                $rc = $?
            }
            'nexus' {
                if (-not $upload.repository) { Write-Fatal "upload.repository is required for type 'nexus'" }
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $targetUrl = "$($url.TrimEnd('/'))/service/rest/v1/raw/$($upload.repository)/$leaf"
                Write-Verbose "Uploading $Source to $targetUrl (nexus)"
                $null = Invoke-WebRequest -Uri $targetUrl -Method Put -InFile $Source -Headers $headers -UseBasicParsing
                $rc = $?
            }
            'smb' {
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $dest = "$url/$leaf"
                Write-Verbose "Copying $Source to $dest (smb)"
                # Note : SMB authentication relies on the current session credentials
                $null = Copy-Item -Path $Source -Destination $dest -Force
                $rc = $?
            }
            'ftp' {
                $uri = [uri]$url
                $leaf = $filename ?? [IO.Path]::GetFileName($Source)
                $remotePath = "$($uri.AbsolutePath.TrimEnd('/'))/$leaf"
                Write-Verbose "Uploading $Source to ${uri.Host}$remotePath (ftp)"
                $client = New-Object System.Net.FtpClient
                $client.Host = $uri.Host
                if ($uri.Port) { $client.Port = $uri.Port }
                if ($uri.Scheme -eq 'ftps') { $client.EnableSsl = $true }
                if ($username) {
                    $client.Credentials = New-Object System.Net.NetworkCredential($username, $password)
                }
                try {
                    $null = $client.UploadFile($Source, $remotePath)
                    $rc = $?
                } finally {
                    $client.Dispose()
                }
            }
            default {
                Write-Fatal "Unknown upload type : '$($upload.type)'"
                $rc = $false
            }
        }
        return $rc
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# WRAPPER : Complete workflow
# resolve → download → verification → install → post → upload
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Install a package from a pkgdef v2 definition.
 
.DESCRIPTION
Main entry point orchestrating the full pipeline for one target :
  1. Resolve : resolve the definition for the selected target
  2. Download : download the asset (+ digest / signature verification)
  3. Install : execute the install steps
  4. Post : execute the post-install steps
  5. Upload : publish the asset when the target declares an upload block
 
Returns a result hashtable : Status (Success/Failed), Phase, Error, Name,
Codename, Version, Target, Asset.
 
.EXAMPLE
$result = Install-GenericPackage -Path ./examples/aria2c.yml -TargetId windows-x64 -DestinationPath 'C:\'
 
.EXAMPLE
$result = Install-GenericPackage -Path ./examples/libreoffice.yml -Os linux -Distro debian -Arch x64 -DestinationPath /mnt/chroot
#>

function Install-GenericPackage {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        # Path to the pkgdef file
        [Parameter(Mandatory = $false)][string]$Path,
        # Package definition (hashtable)
        [Parameter(Mandatory = $false, ValueFromPipeLine = $true)][object]$Definition,
        # Explicit target id
        [Parameter(Mandatory = $false)][string]$TargetId,
        # Target OS (alternative to -TargetId)
        [Parameter(Mandatory = $false)][ValidateSet('windows', 'linux', 'macos')][string]$Os,
        # Target distribution (linux)
        [Parameter(Mandatory = $false)][string]$Distro = '',
        # Target architecture
        [Parameter(Mandatory = $false)][ValidateSet('x86', 'x64', 'arm32', 'arm64', 'all')][string]$Arch = '',
        # Destination path to install (chroot / mount / install root)
        [Parameter(Mandatory = $false)][string]$DestinationPath,
        # Path to the cache directory to download assets to
        [Parameter(Mandatory = $false)][string]$CachePath,
        # Template values (uppercase keys, mustache compatible)
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not $Path -and -not $Definition) { Write-Fatal "Either -Path or -Definition must be provided" }
        if ($Path -and $Definition) { Write-Fatal "Provide either -Path or -Definition, not both" }

        # ─── Load raw definition ──────────────────────────────────────────────
        if ($Path) {
            if (-not (Test-FileExist $Path)) { Write-Fatal "File not found : $Path" }
            $raw = Get-Content -Path $Path -Raw | ConvertFrom-Yaml | ConvertTo-PlainHashtable
        } else {
            $raw = ConvertTo-PlainHashtable -InputObject $Definition
        }

        $result = @{
            Status   = ''
            Phase    = ''
            Error    = ''
            Name     = $raw.name
            Codename = $raw.codename ?? (($raw.name.ToLower() -replace '\s+', '-'))
            Version  = ''
            Target   = $TargetId ?? "$Os/$Distro/$Arch"
            Asset    = ''
        }

        try {
            # ─── Phase 1 : Resolve ────────────────────────────────────────────
            Write-Verbose "Phase 1 : resolving definition"
            # target selection splat : -Os/-Distro/-Arch are only valid when
            # -TargetId is not used (empty values would fail ValidateSet)
            $targetParams = @{}
            if ($TargetId) {
                $targetParams.TargetId = $TargetId
            } else {
                $targetParams.Os = $Os
                $targetParams.Distro = $Distro
                $targetParams.Arch = $Arch
            }
            try {
                $resolved = Resolve-GenericPackageDefinition -Definition $raw @targetParams
            } catch {
                $resolved = $null
                Write-Warning $_.Exception.Message
            }
            if (-not $resolved) {
                $result.Status = 'Failed'; $result.Phase = 'Resolve'; $result.Error = 'definition could not be resolved'
                return $result
            }
            $result.Target = $resolved.target.id
            $result.Version = $resolved.version

            # Seed mustache values from the resolved definition
            # (caller-provided values win over the derived ones)
            $mv = Get-GenericPackageMustacheValues -pkgdef $resolved -overrides $mustacheValues
            $mv.DEST = $DestinationPath

            # Resolve CachePath: parameter > pkgdef.download.destination > temp path
            if ([string]::IsNullOrEmpty($CachePath)) {
                if ($resolved.download.destination) {
                    $CachePath = $resolved.download.destination
                } else {
                    $CachePath = [io.path]::GetTempPath()
                }
            }

            # ─── Phase 2 : Download (+ verification) ──────────────────────────
            Write-Verbose "Phase 2 : downloading asset"
            try {
                $asset = Download-GenericPackageAsset -pkgdef $resolved -CachePath $CachePath -mustacheValues $mv
            } catch {
                $asset = $null
                Write-Warning $_.Exception.Message
            }
            if (-not $asset) {
                $result.Status = 'Failed'; $result.Phase = 'Download'; $result.Error = 'asset download failed'
                return $result
            }
            $result.Asset = $asset
            $mv.ASSET = $asset
            # a provider may have refined the version during download
            # (webscrapper ?<ver> capture) : reflect it in the result
            $result.Version = $mv.VERSION
            Write-Verbose "Downloaded asset : $asset"

            # ─── Phase 3 : Install ────────────────────────────────────────────
            Write-Verbose "Phase 3 : executing install steps"
            $rc = Execute-GenericPackageSteps -Phase 'install' -Steps @($resolved.install) -DestinationPath $DestinationPath -mustacheValues $mv
            if (-not $rc) {
                $result.Status = 'Failed'; $result.Phase = 'Install'; $result.Error = 'install step execution failed'
                return $result
            }

            # ─── Phase 4 : Post ───────────────────────────────────────────────
            Write-Verbose "Phase 4 : executing post-install steps"
            $rc = Execute-GenericPackageSteps -Phase 'post' -Steps @($resolved.post) -DestinationPath $DestinationPath -mustacheValues $mv
            if (-not $rc) {
                $result.Status = 'Failed'; $result.Phase = 'Post'; $result.Error = 'post-install step execution failed'
                return $result
            }

            # ─── Phase 5 : Upload ─────────────────────────────────────────────
            if ($resolved.upload) {
                Write-Verbose "Phase 5 : uploading asset"
                $rc = Invoke-GenericPackageUpload -upload $resolved.upload -Source $asset -mustacheValues $mv
                if (-not $rc) {
                    $result.Status = 'Failed'; $result.Phase = 'Upload'; $result.Error = 'asset upload failed'
                    return $result
                }
            }

            $result.Status = 'Success'
            return $result
        } catch {
            Write-Warning "Unexpected error: $_"
            $result.Status = 'Failed'
            $result.Phase = 'Unknown'
            $result.Error = $_.Exception.Message
            return $result
        }
    }

    End {
        Write-LeaveFunction
    }
}

# ═══════════════════════════════════════════════════════════════════════════════
# PRIVATE HELPERS
# ═══════════════════════════════════════════════════════════════════════════════

<#
.SYNOPSIS
Recursively convert a definition into plain hashtables / object arrays.
 
.DESCRIPTION
powershell-yaml (and PwSh.Fw.Core ConvertTo-Hashtable) produce PSCustomObject
and OrderedDictionary instances. Plain hashtables are safer downstream :
they bind to [hashtable] parameters and behave identically everywhere.
#>

function ConvertTo-PlainHashtable {
    [CmdletBinding()]
    [OutputType([object])]
    Param (
        [AllowNull()][Parameter(Mandatory = $true, ValueFromPipeline = $true)][object]$InputObject
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ($null -eq $InputObject) { return $null }
        if ($InputObject -is [System.Collections.IDictionary]) {
            $h = @{}
            foreach ($k in $InputObject.Keys) { $h[$k] = ConvertTo-PlainHashtable -InputObject $InputObject[$k] }
            return $h
        }
        if ($InputObject -is [System.Management.Automation.PSCustomObject]) {
            $h = @{}
            foreach ($prop in $InputObject.PSObject.Properties) { $h[$prop.Name] = ConvertTo-PlainHashtable -InputObject $prop.Value }
            return $h
        }
        if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) {
            $arr = @()
            foreach ($item in $InputObject) { $arr += ConvertTo-PlainHashtable -InputObject $item }
            return , $arr
        }
        return $InputObject
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Key-by-key merge of two download blocks (target keys win).
#>

function Merge-GenericPackageDownload {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $false)][object]$Global,
        [Parameter(Mandatory = $false)][object]$Override
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # NB : -is [hashtable] would miss OrderedDictionary (what
        # ConvertTo-Hashtable returns for yaml input), hence IDictionary
        $merged = @{}
        if ($Global -is [System.Collections.IDictionary]) { foreach ($k in $Global.Keys) { $merged[$k] = $Global[$k] } }
        if ($Override -is [System.Collections.IDictionary]) { foreach ($k in $Override.Keys) { $merged[$k] = $Override[$k] } }
        return $merged
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Read the API token from the environment variable named by download.token_env.
Tokens are never stored in the definition.
#>

function Get-GenericPackageToken {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [Parameter(Mandatory = $true)][object]$pkgdef
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $token = ''
        if ($pkgdef.download.token_env) {
            $token = [Environment]::GetEnvironmentVariable($pkgdef.download.token_env)
            if ([string]::IsNullOrEmpty($token)) {
                Write-Warning "token_env '$($pkgdef.download.token_env)' is empty or not set"
            }
        }
        return $token
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Build the mustache values table for a resolved definition.
 
.DESCRIPTION
Seeds the standard keys (NAME, CODENAME, VERSION, OS, DISTRO, ARCH) from
the resolved definition, then applies the caller-provided values on top :
explicit values win over the derived ones, like the key-by-key download
merge. TAGNAME is added later by the providers once the release is
resolved ; DEST and ASSET are added by Install-GenericPackage.
#>

function Get-GenericPackageMustacheValues {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $true)][object]$pkgdef,
        [Parameter(Mandatory = $false)][hashtable]$overrides = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        $mv = @{}
        if ($pkgdef.name) { $mv.NAME = $pkgdef.name }
        if ($pkgdef.codename) { $mv.CODENAME = $pkgdef.codename }
        if ($pkgdef.version) { $mv.VERSION = $pkgdef.version }
        if ($pkgdef.target) {
            if ($pkgdef.target.os) { $mv.OS = $pkgdef.target.os }
            if ($pkgdef.target.distro) { $mv.DISTRO = $pkgdef.target.distro }
            if ($pkgdef.target.arch) { $mv.ARCH = $pkgdef.target.arch }
        }
        foreach ($k in $overrides.Keys) { $mv[$k] = $overrides[$k] }
        return $mv
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Render a mustache template field when it contains placeholders.
#>

function Render-MustacheField {
    [CmdletBinding()]
    [OutputType([string])]
    Param (
        [AllowNull()][AllowEmptyString()][string]$value,
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{}
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ([string]::IsNullOrEmpty($value)) { return $value }
        if (Test-MustacheTemplate $value) {
            return ConvertFrom-MustacheTemplate -Template $value -values $mustacheValues
        }
        return $value
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Select the first asset matching the ordered pattern list.
 
.DESCRIPTION
Shared pattern-matching loop for the providers. -Names are the candidate
names (release asset names or page link URLs), -Assets the corresponding
objects, -AssetSpecs the ordered list of download.assets entries
({ pattern ; signature-file }). Returns @{ Index ; Spec } or $null.
#>

function Select-GenericPackageAsset {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        [Parameter(Mandatory = $true)][string[]]$Names,
        [Parameter(Mandatory = $true)][object[]]$Assets,
        # may be absent : a definition without assets[] cannot match anything
        [Parameter(Mandatory = $false)][object[]]$AssetSpecs,
        [Parameter(Mandatory = $false)][hashtable]$mustacheValues = @{},
        # when the matched pattern defines a named group 'ver', the candidate
        # holding the highest captured version wins instead of the first match
        [switch]$PickHighestVersion
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        # filter null entries : -match against a null pattern would match anything
        $specs = @($AssetSpecs | Where-Object { $null -ne $_ })
        if (-not $specs.Count) { return $null }
        foreach ($spec in $specs) {
            $pattern = $spec.pattern
            if (Test-MustacheTemplate $pattern) {
                $pattern = ConvertFrom-MustacheTemplate -Template $pattern -values $mustacheValues
            }
            Write-Devel "Looking for asset matching pattern '$pattern'"
            $candidates = @()
            for ($i = 0; $i -lt $Names.Count; $i++) {
                if ($Names[$i] -match $pattern) {
                    if (-not $PickHighestVersion) {
                        return @{ Index = $i; Spec = $spec; CapturedVersion = $null }
                    }
                    $candidate = @{ Index = $i; Captured = $Matches['ver'] }
                    try { $candidate.V = [version]$Matches['ver'] } catch { $candidate.V = $null }
                    $candidates += $candidate
                }
            }
            if ($candidates.Count) {
                # highest parseable captured version wins, else first match
                $withVersion = @($candidates | Where-Object { $null -ne $_.V } | Sort-Object -Property V -Descending)
                if ($withVersion.Count) {
                    return @{ Index = $withVersion[0].Index; Spec = $spec; CapturedVersion = $withVersion[0].Captured }
                }
                return @{ Index = $candidates[0].Index; Spec = $spec; CapturedVersion = $null }
            }
        }
        return $null
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Verify a downloaded asset against a signature (checksum) file.
 
.DESCRIPTION
The signature file is expected to contain the hex hash, optionally followed
by the file name (sha256sum style : '<hash> <filename>' or bare '<hash>').
#>

function Test-GenericPackageSignature {
    [CmdletBinding()]
    [OutputType([System.Boolean])]
    Param (
        [Parameter(Mandatory = $true)][string]$AssetPath,
        [Parameter(Mandatory = $true)][string]$SignaturePath,
        [Parameter(Mandatory = $true)][ValidateSet('md5', 'sha1', 'sha256', 'sha512')][string]$Algorithm
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (-not (Test-FileExist $SignaturePath)) {
            Write-Error "Signature file not found : $SignaturePath"
            return $false
        }
        $expected = $null
        foreach ($line in (Get-Content $SignaturePath)) {
            if ($line -match '^\s*(?<hash>[0-9a-fA-F]{8,})\b') {
                $expected = $Matches.hash.ToUpper()
                break
            }
        }
        if (-not $expected) {
            Write-Error "Could not extract a hash from signature file : $SignaturePath"
            return $false
        }
        $actual = (Get-FileHash -Algorithm $Algorithm.ToUpper() -Path $AssetPath).Hash.ToUpper()
        if ($actual -eq $expected) {
            Write-Success "Signature ($Algorithm) verified : $([IO.Path]::GetFileName($AssetPath))"
            return $true
        }
        Write-Error "Signature mismatch ($Algorithm) for $([IO.Path]::GetFileName($AssetPath)) : expected $expected, got $actual"
        return $false
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Convert an object or a hashtable to a mustache values hashtable.
 
.DESCRIPTION
Converts only the 1st level of object/hashtable attributes and uppercases
the key names so you can use {{ KEY }} to increase readability.
#>

function ConvertTo-MustacheValuesHashtable {
    [CmdletBinding()]
    [OutputType([hashtable])]
    Param (
        # Object to convert to an uppercased keys, single level hashtable
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$InputObject
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        Write-Debug "Convert object to hashtable"
        if ($InputObject -is [hashtable]) { $hash = $InputObject } else { $hash = $InputObject | ConvertTo-Hashtable }
        Write-Debug "Uppercase keys and trim collections values"
        $HASHTABLE = @{}
        foreach ($key in $hash.Keys) {
            Write-Devel "$key = $($hash.$key)"
            if ($hash.$key -isnot [System.Collections.IEnumerable] -or $hash.$key -is [string]) {
                $upperKey = $key.ToUpper()
                $HASHTABLE.$upperKey = $hash.$key
            }
        }
        return $HASHTABLE
    }

    End {
        Write-LeaveFunction
    }
}

function Test-MustacheTemplate {
    [CmdletBinding()]
    [OutputType([boolean])]
    Param (
        [AllowEmptyString()][AllowNull()]
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$string
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        return ($string -match '{{ [a-zA-Z0-9_.-]+ }}')
    }

    End {
        Write-LeaveFunction
    }
}

# ─── Load provider implementations ────────────────────────────────────────────
# NB : providers are .ps1 files, NOT .psm1 : dot-sourcing a .psm1 file makes
# PowerShell import it as a separate module instead of executing it in module
# scope, so the provider functions would not be visible to this module.
foreach ($include in (Get-ChildItem -Path "$PSScriptRoot/Includes" -Filter '*.ps1' -ErrorAction SilentlyContinue)) {
    . $include.FullName
}

# ─── Export public API ────────────────────────────────────────────────────────
Export-ModuleMember -Function @(
    'Resolve-GenericPackageDefinition',
    'Expand-GenericPackageTargets',
    'Test-GenericPackageDefinition',
    'Download-GenericPackageAsset',
    'Get-GenericPackageAssetInfo',
    'Install-GenericPackage'
)