Private/Get-WingetPackage.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Querying and removing winget packages, with verification.
 
.DESCRIPTION
Two mistakes made the old uninstaller unable to remove anything:
 
  winget show --id Gyan.FFmpeg queries the *catalogue*, not the machine, so
                                 it succeeds for any package that exists at
                                 all - FFmpeg was offered for removal even
                                 when it had never been installed.
 
  winget uninstall "Python.Python"
                                 is not a real package id (the installed one
                                 is Python.Python.3.12), and because a native
                                 command does not throw, the surrounding
                                 try/catch never fired. The uninstaller
                                 reported success while nothing happened.
 
So: presence is decided by `winget list`, ids are read from what is actually
installed, and every removal is confirmed by querying again afterwards.
#>


function Test-WingetPresent {
    [OutputType([bool])]
    param()
    return [bool](Get-Command winget.exe -ErrorAction SilentlyContinue)
}

function Get-InstalledWingetPackage {
    <#
    .SYNOPSIS
    Returns the installed packages whose id matches a pattern.
 
    .PARAMETER IdPrefix
    Passed to `winget list --id`, which matches on substring.
 
    .PARAMETER IdPattern
    Regex the id must match, applied to winget's output. Matching the id
    rather than parsing columns keeps this independent of column widths and
    of the display language.
 
    .OUTPUTS
    Array of hashtables with Id and Version. Empty when nothing is installed.
    #>

    [OutputType([hashtable[]])]
    param(
        [Parameter(Mandatory = $true)][string]$IdPrefix,
        [Parameter(Mandatory = $true)][string]$IdPattern
    )

    $found = @()
    if (-not (Test-WingetPresent)) { return $found }

    try {
        $output = & winget.exe list --id $IdPrefix --accept-source-agreements 2>&1 |
            ForEach-Object { "$_" }
    } catch {
        return $found
    }

    # A non-zero exit means "no installed package found", which is an answer,
    # not a failure.
    if ($LASTEXITCODE -ne 0) { return $found }

    return (ConvertFrom-WingetListOutput -Lines $output -IdPattern $IdPattern)
}

function ConvertFrom-WingetListOutput {
    <#
    .SYNOPSIS
    Extracts package ids and versions from `winget list` output.
 
    .DESCRIPTION
    Matches the id by regex rather than splitting the table into columns:
    winget pads columns to the terminal width and localises the headers, so
    column positions are not dependable. Kept separate from the winget call so
    it can be tested against captured output.
 
    .OUTPUTS
    Array of hashtables with Id and Version.
    #>

    [OutputType([hashtable[]])]
    param(
        [string[]]$Lines,
        [Parameter(Mandatory = $true)][string]$IdPattern
    )

    $found = @()
    $seen = @{}

    foreach ($line in $Lines) {
        if ($line -notmatch $IdPattern) { continue }

        $id = $Matches[0]
        if ($seen.ContainsKey($id)) { continue }

        # The version is the token following the id on the same row.
        $version = "unknown"
        if ($line -match ([regex]::Escape($id) + '\s+(\S+)')) {
            $version = $Matches[1]
        }

        $seen[$id] = $true
        $found += @{ Id = $id; Version = $version }
    }

    return $found
}

function Uninstall-WingetPackage {
    <#
    .SYNOPSIS
    Uninstalls one package by exact id and verifies it is gone.
 
    .DESCRIPTION
    winget's exit code is treated as advisory. What counts is whether the
    package still shows up afterwards, which is the only claim worth making
    to the user.
 
    .OUTPUTS
    Hashtable with Removed, ExitCode, Detail.
    #>

    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)][string]$Id,
        [string]$LogPath
    )

    if (-not (Test-WingetPresent)) {
        return @{ Removed = $false; ExitCode = $null; Detail = "winget is not available" }
    }

    try {
        $output = & winget.exe uninstall --id $Id --exact --silent `
            --accept-source-agreements --disable-interactivity 2>&1 |
            ForEach-Object { "$_" }
        $exitCode = $LASTEXITCODE
    } catch {
        return @{ Removed = $false; ExitCode = $null; Detail = $_.Exception.Message }
    }

    if ($LogPath) {
        foreach ($line in ($output | Select-Object -Last 8)) {
            "winget uninstall ${Id}: $line" | Add-Content $LogPath -Encoding UTF8 -ErrorAction SilentlyContinue
        }
    }

    # Verify against the machine rather than trusting the exit code.
    $stillInstalled = @(Get-InstalledWingetPackage -IdPrefix $Id -IdPattern ([regex]::Escape($Id)))

    if ($stillInstalled.Count -eq 0) {
        return @{ Removed = $true; ExitCode = $exitCode; Detail = "verified removed" }
    }

    $detail = if ($exitCode -eq 0) {
        "winget reported success but the package is still installed"
    } else {
        $lastLine = ($output | Where-Object { $_ -match '\S' } | Select-Object -Last 1)
        "winget exit code $exitCode - $lastLine"
    }

    return @{ Removed = $false; ExitCode = $exitCode; Detail = $detail }
}

Export-ModuleMember -Function @(
    'Test-WingetPresent',
    'Get-InstalledWingetPackage',
    'ConvertFrom-WingetListOutput',
    'Uninstall-WingetPackage'
)