WinGetProvider.ps1

function Get-WinGetPackageProvider {
    <#
    .SYNOPSIS
        Describe the Windows-only WinGet adapter without probing dependencies.
    .DESCRIPTION
        Uses Microsoft.WinGet.Client 1.8.1911+ for structured installed-package
        discovery, including its default acceptance of source agreements.
        Source restricts discovery; Include and Exclude are package-ID wildcards.
        AcceptPackageAgreements is Boolean and defaults to false.
        The core approves each exact package/source target before native upgrade.
    #>

    [CmdletBinding()]
    param()

    [pscustomobject]@{
        Name = 'WinGet'
        Platforms = @('Windows')
        RequiredModules = @()
        RequiredCommands = @()
        OptionNames = @('Source', 'Include', 'Exclude', 'AcceptPackageAgreements')
        TestAvailable = {
            param([hashtable]$Options)
            $null = Resolve-WinGetProviderOptions -Options $Options
            $cli = Get-Command winget.exe -CommandType Application -ListImported -ErrorAction Ignore |
                Select-Object -First 1
            if (-not $cli -or [IO.Path]::GetExtension($cli.Path) -ine '.exe') {
                return [pscustomobject]@{ Available = $false; Reason = "Install WinGet 1.8.1911 or later and expose winget.exe on PATH." }
            }
            $versionResult = Invoke-WinGetProviderNative -FilePath $cli.Path -Arguments @('--version')
            $versionText = ($versionResult.Output -join '').Trim()
            if ($versionText -notmatch '\Av(\d+\.\d+\.\d+(?:\.\d+)?)\z' -or
                [version]$Matches[1] -lt [version]'1.8.1911') {
                return [pscustomobject]@{ Available = $false; Reason = "WinGet requires a stable CLI version 1.8.1911 or later for silent authentication; found '$versionText'." }
            }
            $module = Get-Module Microsoft.WinGet.Client | Sort-Object Version -Descending | Select-Object -First 1
            if (-not $module) {
                $module = Get-Module -ListAvailable Microsoft.WinGet.Client -ErrorAction Stop |
                    Where-Object Version -GE ([version]'1.8.1911') | Sort-Object Version -Descending | Select-Object -First 1
            }
            if (-not $module -or $module.Version -lt [version]'1.8.1911') {
                return [pscustomobject]@{ Available = $false; Reason = "Install Microsoft.WinGet.Client 1.8.1911 or later separately. If an older version is loaded, start a new session after upgrading." }
            }
            Import-Module -Name $module.Path -ErrorAction Stop
            $command = Get-Command 'Microsoft.WinGet.Client\Get-WinGetPackage' -ListImported -ErrorAction Ignore
            if (-not $command -or @('Id', 'Source', 'MatchOption' | Where-Object { -not $command.Parameters.ContainsKey($_) }).Count) {
                return [pscustomobject]@{ Available = $false; Reason = "Microsoft.WinGet.Client must provide Get-WinGetPackage with Id, Source and MatchOption parameters." }
            }
            [pscustomobject]@{ Available = $true; Reason = $null }
        }
        GetTargets = {
            param([hashtable]$Options)
            $settings = Resolve-WinGetProviderOptions -Options $Options
            $arguments = @{ ErrorAction = 'Stop' }
            if ($settings.Source) { $arguments.Source = $settings.Source }
            $cli = Get-Command winget.exe -CommandType Application -ListImported -ErrorAction Stop | Select-Object -First 1
            $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
            foreach ($package in @(Microsoft.WinGet.Client\Get-WinGetPackage @arguments)) {
                if ($package.Id -isnot [string] -or $package.IsUpdateAvailable -isnot [bool]) {
                    throw 'WinGet discovery returned invalid structured package data.'
                }
                if (-not $package.IsUpdateAvailable -or -not (Test-WinGetProviderFilter -Id $package.Id -Settings $settings)) { continue }
                Assert-WinGetProviderIdentity -Id $package.Id -Source $package.Source
                if ($settings.Source -and $settings.Source -ine $package.Source) {
                    throw "WinGet returned package '$($package.Id)' from a different source than requested."
                }
                $identity = "$($package.Id) (source: $($package.Source))"
                if (-not $seen.Add($identity)) { throw "WinGet returned an ambiguous installed target '$identity'." }
                New-PackageUpdateTarget -Target $identity -PreviousVersion (Get-WinGetProviderVersion -Value $package.InstalledVersion) -Data @{
                    Id = $package.Id
                    Source = $package.Source
                    FilePath = $cli.Path
                }
            }
        }
        Update = {
            param($Target, [hashtable]$Options)
            Update-WinGetProviderTarget -Target $Target -Options $Options
        }
    }
}

function Resolve-WinGetProviderOptions {
    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$Options)

    $source = $null
    if ($Options.ContainsKey('Source')) {
        Assert-WinGetProviderIdentity -Id 'Validation' -Source $Options.Source
        $source = $Options.Source
    }
    $filters = @{}
    foreach ($name in 'Include', 'Exclude') {
        $patterns = if ($name -eq 'Include') { @('*') } else { @() }
        if ($Options.ContainsKey($name)) {
            if ($Options[$name] -isnot [string] -and $Options[$name] -isnot [array]) {
                throw "WinGet $name must be a package-ID wildcard string or an array of strings."
            }
            $patterns = @($Options[$name])
        }
        $filters[$name] = @(
            foreach ($pattern in $patterns) {
                if ($pattern -isnot [string] -or [string]::IsNullOrWhiteSpace($pattern) -or $pattern -match '[\x00-\x1f\x7f]') {
                    throw "WinGet $name must contain nonempty wildcard strings without control characters."
                }
                $wildcard = [System.Management.Automation.WildcardPattern]::new($pattern, 'IgnoreCase')
                $null = $wildcard.IsMatch('')
                $wildcard
            }
        )
    }
    $acceptPackages = $false
    if ($Options.ContainsKey('AcceptPackageAgreements')) {
        if ($Options.AcceptPackageAgreements -isnot [bool]) { throw 'WinGet AcceptPackageAgreements must be a Boolean.' }
        $acceptPackages = $Options.AcceptPackageAgreements
    }
    [pscustomobject]@{
        Source = $source
        Include = $filters.Include
        Exclude = $filters.Exclude
        AcceptPackageAgreements = $acceptPackages
    }
}

function Test-WinGetProviderFilter {
    param([string]$Id, [psobject]$Settings)

    foreach ($pattern in $Settings.Exclude) { if ($pattern.IsMatch($Id)) { return $false } }
    foreach ($pattern in $Settings.Include) { if ($pattern.IsMatch($Id)) { return $true } }
    return $false
}

function Assert-WinGetProviderIdentity {
    param([AllowNull()][object]$Id, [AllowNull()][object]$Source)

    if ($Id -isnot [string] -or $Id -cnotmatch '\A[A-Za-z0-9][A-Za-z0-9._+-]*\z') {
        throw 'WinGet package IDs must contain only ASCII letters, digits, periods, underscores, plus signs and hyphens, starting with a letter or digit.'
    }
    if ($Source -isnot [string] -or $Source -cnotmatch '\A[A-Za-z0-9][A-Za-z0-9._ -]*\z' -or $Source.Trim() -cne $Source) {
        throw 'WinGet source names must contain only ASCII letters, digits, periods, underscores, spaces and hyphens, starting with a letter or digit, without trailing spaces.'
    }
}

function Get-WinGetProviderVersion {
    param([AllowNull()][object]$Value)

    if ($null -eq $Value -or ($Value -is [string] -and ([string]::IsNullOrWhiteSpace($Value) -or $Value -ieq 'Unknown'))) { return $null }
    if ($Value -isnot [string]) { throw 'WinGet returned an invalid installed version.' }
    return $Value
}

function Invoke-WinGetProviderNative {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$FilePath,
        [Parameter(Mandatory)][string[]]$Arguments,
        [switch]$AllowNoUpdate
    )

    if ([IO.Path]::GetExtension($FilePath) -ine '.exe') { throw 'WinGet requires a native .exe, not a batch shim or shell command.' }
    $PSNativeCommandUseErrorActionPreference = $false
    $previousExitCode = Get-Variable LASTEXITCODE -Scope Global -ErrorAction Ignore
    $previousValue = if ($previousExitCode) { $previousExitCode.Value } else { $null }
    try {
        $global:LASTEXITCODE = $null
        $output = @(& $FilePath @Arguments 2>&1)
        $exitCode = $global:LASTEXITCODE
        if ($exitCode -isnot [int]) { throw 'WinGet did not report a numeric native exit code; the outcome is unknown.' }
        # APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE is the sole benign nonzero result.
        $noUpdate = $AllowNoUpdate -and $exitCode -eq ([int]0x8A15002B)
        if ($exitCode -ne 0 -and -not $noUpdate) {
            throw "WinGet $($Arguments[0]) failed (exit $exitCode): $($output -join [Environment]::NewLine)"
        }
        [pscustomobject]@{ NoUpdate = [bool]$noUpdate; Output = @($output | ForEach-Object { $_.ToString() }) }
    } finally {
        if ($previousExitCode) { $global:LASTEXITCODE = $previousValue }
        else { Remove-Variable LASTEXITCODE -Scope Global -ErrorAction Ignore -WhatIf:$false -Confirm:$false }
    }
}

function Update-WinGetProviderTarget {
    <#
    .SYNOPSIS
        Upgrade one source-bound target already approved by the aggregate command.
    .DESCRIPTION
        Private callback: core ShouldProcess is the only confirmation boundary.
        Source agreements are accepted by default, matching structured discovery.
        Package agreements are accepted only when explicitly enabled.
        Native completion and a fresh installed-package observation are required.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][psobject]$Target,
        [Parameter(Mandatory)][hashtable]$Options
    )

    $settings = Resolve-WinGetProviderOptions -Options $Options
    $id = $Target.Data.Id
    $source = $Target.Data.Source
    Assert-WinGetProviderIdentity -Id $id -Source $source
    if ($Target.Target -cne "$id (source: $source)" -or
        ($settings.Source -and $settings.Source -ine $source) -or
        -not (Test-WinGetProviderFilter -Id $id -Settings $settings)) {
        throw 'WinGet target identity does not match its approved package, source or filters.'
    }
    $arguments = @('upgrade', '--id', $id, '--exact', '--source', $source,
        '--silent', '--disable-interactivity', '--authentication-mode', 'silent', '--accept-source-agreements')
    if ($settings.AcceptPackageAgreements) { $arguments += '--accept-package-agreements' }
    $completion = Invoke-WinGetProviderNative -FilePath $Target.Data.FilePath -Arguments $arguments -AllowNoUpdate
    foreach ($line in $completion.Output) { Write-Verbose $line }
    $observed = @(Microsoft.WinGet.Client\Get-WinGetPackage -Id $id -Source $source -MatchOption Equals -ErrorAction Stop)
    if ($observed.Count -ne 1 -or $observed[0].Id -cne $id -or $observed[0].Source -ine $source) {
        throw "Cannot observe exactly one installed WinGet package '$id' from source '$source' after upgrade."
    }
    $version = Get-WinGetProviderVersion -Value $observed[0].InstalledVersion
    $reason = $null
    if ($completion.NoUpdate) {
        $status = 'Unchanged'
        $reason = 'WinGet reported no applicable update (0x8A15002B).'
    } elseif ($version -and $Target.PreviousVersion -and $version -ceq $Target.PreviousVersion) {
        $status = 'Unchanged'
    } else {
        $status = 'Updated'
        if (-not $version -or -not $Target.PreviousVersion) {
            $reason = 'WinGet completed the upgrade successfully; unknown installed versions prevent a version comparison.'
        }
    }
    New-PackageUpdateResult -Provider WinGet -Target $Target.Target -PreviousVersion $Target.PreviousVersion -ResultingVersion $version -Status $status -Reason $reason
}