Private/Get-FoPluginBundleManifest.ps1

$script:FoPluginBundleManifestFileName = 'fo-plugin-bundle.json'
$script:FoPluginBundleExcludePatterns = @('*.bat')

function Get-FoPluginBundleManifestFileName {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    return $script:FoPluginBundleManifestFileName
}

function Get-FoMinimumPluginBundleVersion {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    return $script:FoMinimumPluginBundleVersion
}

function Test-FoPluginBundleFileExcluded {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$FileName
    )

    foreach ($pattern in $script:FoPluginBundleExcludePatterns) {
        if ($FileName -like $pattern) { return $true }
    }
    if ($FileName -ieq $script:FoPluginBundleManifestFileName) { return $true }
    return $false
}

function Compare-FoPluginBundleVersion {
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [string]$Left,
        [Parameter(Mandatory)]
        [string]$Right
    )

    $leftVersion = [version]$Left
    $rightVersion = [version]$Right
    return $leftVersion.CompareTo($rightVersion)
}

function New-FoPluginBundleManifestObject {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$PluginDirectory,
        [Parameter(Mandatory)]
        [string]$BundleVersion,
        [Parameter(Mandatory)]
        [ValidateSet('32', '64')]
        [string]$Architecture,
        [string]$SourceBundleVersion,
        [datetime]$CreatedUtc = [datetime]::UtcNow
    )

    $folder = Get-FoPluginBundleFolderName -Architecture $Architecture
    $files = [System.Collections.Generic.List[object]]::new()

    Get-ChildItem -LiteralPath $PluginDirectory -File -ErrorAction Stop |
        Where-Object { -not (Test-FoPluginBundleFileExcluded -FileName $_.Name) } |
        Sort-Object Name |
        ForEach-Object {
            $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
            $files.Add(@{
                Path   = $_.Name
                Sha256 = $hash
                Size   = [int64]$_.Length
            })
        }

    $manifest = @{
        SchemaVersion = 1
        BundleVersion = $BundleVersion
        Architecture  = $Architecture
        Folder        = $folder
        CreatedUtc    = $CreatedUtc.ToString('o')
        Files         = @($files)
    }
    if ($SourceBundleVersion) {
        $manifest.SourceBundleVersion = $SourceBundleVersion
    }
    return $manifest
}

function Save-FoPluginBundleManifest {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Manifest,
        [Parameter(Mandatory)]
        [string]$Path
    )

    Save-FoJsonFile -Path $Path -Data $Manifest -Depth 8
}

function Import-FoPluginBundleManifest {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [string]$Path
    )

    $data = Import-FoJsonFile -Path $Path
    if (-not $data.BundleVersion) {
        throw "Plugin bundle manifest is missing BundleVersion: $Path"
    }
    if (-not $data.Files) {
        throw "Plugin bundle manifest is missing Files: $Path"
    }
    return $data
}

function Get-FoInstalledPluginBundleInfo {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [string]$PluginPath
    )

    if (-not $PluginPath -or -not (Test-Path -LiteralPath $PluginPath)) {
        return [PSCustomObject]@{
            Found         = $false
            Path          = $null
            BundleVersion = $null
            Architecture  = $null
            Manifest      = $null
            Error         = if ($PluginPath) { 'PluginPath not found' } else { 'PluginPath not set' }
        }
    }

    $manifestPath = Join-Path $PluginPath $script:FoPluginBundleManifestFileName
    if (-not (Test-Path -LiteralPath $manifestPath)) {
        return [PSCustomObject]@{
            Found         = $false
            Path          = $manifestPath
            BundleVersion = $null
            Architecture  = $null
            Manifest      = $null
            Error         = 'Manifest not found'
        }
    }

    try {
        $manifest = Import-FoPluginBundleManifest -Path $manifestPath
        return [PSCustomObject]@{
            Found         = $true
            Path          = $manifestPath
            BundleVersion = [string]$manifest.BundleVersion
            Architecture  = [string]$manifest.Architecture
            Manifest      = $manifest
            Error         = $null
        }
    }
    catch {
        return [PSCustomObject]@{
            Found         = $false
            Path          = $manifestPath
            BundleVersion = $null
            Architecture  = $null
            Manifest      = $null
            Error         = $_.Exception.Message
        }
    }
}

function Test-FoPluginBundleManifestFiles {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Manifest,
        [Parameter(Mandatory)]
        [string]$PluginDirectory
    )

    $mismatched = [System.Collections.Generic.List[string]]::new()
    $missing = [System.Collections.Generic.List[string]]::new()

    foreach ($entry in @($Manifest.Files)) {
        $rel = [string]$entry.Path
        $full = Join-Path $PluginDirectory $rel
        if (-not (Test-Path -LiteralPath $full)) {
            $missing.Add($rel)
            continue
        }
        $actual = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash.ToLowerInvariant()
        $expected = ([string]$entry.Sha256).ToLowerInvariant()
        if ($actual -ne $expected) {
            $mismatched.Add($rel)
        }
    }

    return [PSCustomObject]@{
        Ok          = ($missing.Count -eq 0 -and $mismatched.Count -eq 0)
        Missing     = @($missing)
        Mismatched  = @($mismatched)
    }
}

function Find-FoPluginBundleManifestPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$ExtractRoot
    )

    $direct = Join-Path $ExtractRoot $script:FoPluginBundleManifestFileName
    if (Test-Path -LiteralPath $direct) {
        return ([System.IO.Path]::GetFullPath($direct))
    }

    foreach ($dir in @(Get-ChildItem -LiteralPath $ExtractRoot -Directory -ErrorAction SilentlyContinue)) {
        $candidate = Join-Path $dir.FullName $script:FoPluginBundleManifestFileName
        if (Test-Path -LiteralPath $candidate) {
            return ([System.IO.Path]::GetFullPath($candidate))
        }
    }

    return $null
}

function Set-FoAcknowledgedPluginBundleMinimum {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$MinimumVersion,
        [string]$ConfigPath
    )

    $path = if ($ConfigPath) {
        $ConfigPath
    }
    else {
        Get-FoGlobalConfigPath
    }

    $data = @{}
    if (Test-Path -LiteralPath $path) {
        $data = Import-FoJsonFile -Path $path
        if ($null -eq $data) { $data = @{} }
    }

    if ($PSCmdlet.ShouldProcess($path, "Set AcknowledgedPluginBundleMinimum=$MinimumVersion")) {
        $data['AcknowledgedPluginBundleMinimum'] = $MinimumVersion
        Save-FoJsonFile -Path $path -Data $data -Depth 10
    }
}

function Test-FoPluginDirectoryHasBinaries {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [string]$PluginPath
    )

    if (-not $PluginPath -or -not (Test-Path -LiteralPath $PluginPath)) {
        return $false
    }

    return [bool](
        Get-ChildItem -LiteralPath $PluginPath -File -ErrorAction SilentlyContinue |
            Where-Object {
                $_.Name -ine $script:FoPluginBundleManifestFileName -and
                ($_.Extension -ieq '.exe' -or $_.Extension -ieq '.dll')
            } |
            Select-Object -First 1
    )
}

function Assert-FoPluginBundleInstalled {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Settings
    )

    # PathOnly users rely on PATH tools, not a portable Install-FoPlugins tree.
    if ([string]$Settings.PluginSearchMode -eq 'PathOnly') {
        return
    }

    if (Test-FoPluginDirectoryHasBinaries -PluginPath $Settings.PluginPath) {
        return
    }

    $hintPath = if ($Settings.PluginPath) { $Settings.PluginPath } else { '{ModuleRoot}\Plugins64 (or Plugins32)' }
    throw ("Plugin bundle is not installed at '{0}'. Run Install-FoPlugins (or .\Scripts\Install-Plugins.ps1) to download the plugin tools, then retry. To use only PATH tools, set PluginSearchMode to PathOnly." -f $hintPath)
}

function Assert-FoPluginBundleVersionForOptimize {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Settings
    )

    Assert-FoPluginBundleInstalled -Settings $Settings

    $minimum = Get-FoMinimumPluginBundleVersion
    if (-not $minimum) { return }

    # PathOnly / empty portable tree already handled above for portable modes.
    if (-not (Test-FoPluginDirectoryHasBinaries -PluginPath $Settings.PluginPath)) {
        return
    }

    $info = Get-FoInstalledPluginBundleInfo -PluginPath $Settings.PluginPath
    $installed = if ($info.Found -and $info.BundleVersion) {
        [string]$info.BundleVersion
    }
    else {
        '0.0.0'
    }

    if ((Compare-FoPluginBundleVersion -Left $installed -Right $minimum) -ge 0) {
        return
    }

    $ack = [string]$Settings.AcknowledgedPluginBundleMinimum
    if ($ack -and ((Compare-FoPluginBundleVersion -Left $ack -Right $minimum) -ge 0)) {
        Write-Warning ("Installed plugin bundle version '{0}' is below required minimum '{1}' (acknowledgment recorded). Run Install-FoPlugins to upgrade." -f $installed, $minimum)
        return
    }

    $detail = if ($info.Error) { " ($($info.Error))" } else { '' }
    throw ("Installed plugin bundle version '{0}' is below required minimum '{1}'{2}. Run Install-FoPlugins to upgrade, or pass -AcknowledgeOutdatedPlugins to continue with a warning until the next required plugin-bundle upgrade." -f $installed, $minimum, $detail)
}