Get-WingetList.ps1


<#PSScriptInfo
 
.VERSION 0.0.3
 
.GUID fd8842b4-23d7-4180-a482-f5c0c23f504c
 
.AUTHOR bfcns
 
.COMPANYNAME
 
.COPYRIGHT
 
.TAGS microsoft.winGet.client winget wingetlist get-wingetlist microsoft.winget winget.client
 
.LICENSEURI https://github.com/bfcns/wingetlist/blob/main/LICENSE
 
.PROJECTURI https://github.com/bfcns/wingetlist
 
.ICONURI
 
.EXTERNALMODULEDEPENDENCIES Microsoft.WinGet.Client Winget
 
.REQUIREDSCRIPTS
 
.EXTERNALSCRIPTDEPENDENCIES
 
.RELEASENOTES
 
.PRIVATEDATA
 
#>


<#
     
.SYNOPSIS
    Retrieves and displays a list of installed packages from winget and Microsoft Store, with options to export reports and fetch VirusTotal links.
 
.DESCRIPTION
    The Get-WingetList script enumerates installed packages using the WinGet PowerShell client and displays them grouped by source (winget, msstore, or none) and update availability.
    It can optionally export the package list to Markdown or HTML reports, and fetch VirusTotal links for winget packages by retrieving their installer SHA256 hashes.
    The script maintains a local cache of SHA256 hashes to minimize redundant lookups.
 
.PARAMETER Name
    Winget List
 
.PARAMETER IncludeVirusTotalLink
    -i
    Includes VirusTotal links for winget packages in the output and exported reports, using cached SHA256 hashes.
 
.PARAMETER UpdateVirusTotalLink
    -u
    Updates the local cache of SHA256 hashes for winget packages by querying 'winget show' for each package. This enables accurate VirusTotal links.
 
.PARAMETER ExportMarkdown
    -m
    Exports the package list to a Markdown file named 'WingetListReport.md' in the current directory.
 
.PARAMETER ExportHtml
    -h
    Exports the package list to an HTML file named 'WingetListReport.html' in the current directory.
 
.EXAMPLE
    .\Get-WingetList.ps1
 
    Displays the grouped list of installed packages in the console.
 
.EXAMPLE
    .\Get-WingetList.ps1 -IncludeVirusTotalLink
 
    Displays the package list with VirusTotal links for winget packages.
 
.EXAMPLE
    .\Get-WingetList.ps1 -ExportMarkdown
 
    Exports the package list to a Markdown report.
 
.EXAMPLE
    .\Get-WingetList.ps1 -UpdateVirusTotalLink
 
    Updates the SHA256 cache for winget packages and enables accurate VirusTotal links.
 
.NOTES
    - Requires PowerShell 7.0 or later.
    - Requires 'winget.exe' and the 'Microsoft.WinGet.Client' PowerShell module.
    - The SHA256 cache is stored in 'wingetlist-sha-cache.json' in the script directory.
    - VirusTotal links are generated using the installer SHA256 hash for each winget package.
 
.LINK
    https://github.com/microsoft/winget-cli
    https://www.powershellgallery.com/packages/Microsoft.WinGet.Client
    https://www.virustotal.com
#>


#Requires -PSEdition Core -Version 7.0

[CmdletBinding()]
param(
    [string]$Name,
    [Alias('i')][switch]$IncludeVirusTotalLink,
    [Alias('u')][switch]$UpdateVirusTotalLink,
    [Alias('m')][switch]$ExportMarkdown,
    [Alias('h')][switch]$ExportHtml
)

$wingetExePath = Get-Command winget.exe -ErrorAction SilentlyContinue
if (-not $wingetExePath) {
    Write-Warning "winget.exe not found."
    exit
}

$winGetCmdPath = Get-Command Get-WinGetPackage -ErrorAction SilentlyContinue
if (-not $winGetCmdPath) {
    Write-Warning "Get-WinGetPackage PowerShell command not found. Install https://www.powershellgallery.com/packages/Microsoft.WinGet.Client/1.10.340"
    exit
}

$packages = Get-WinGetPackage | Sort-Object Source, Name -ErrorAction SilentlyContinue

$update = $false
$shacacheFile = "$PSScriptRoot\wingetlist-sha-cache.json"

$shaCache = @{}
if (Test-Path $shaCacheFile) {
    $raw = Get-Content $shaCacheFile | ConvertFrom-Json
    foreach ($entry in $raw.PSObject.Properties) {
        $shaCache[$entry.Name] = $entry.Value
    }
}

function Get-VirusTotalLink($id) {
    if ($shaCache.ContainsKey($id) -and $shaCache[$id].SHA) {
        $sha = $shaCache[$id].SHA
        return "https://www.virustotal.com/gui/file/$sha"
    }
    return "================== Run with -UpdateVirusTotalLink to update missing ones in cache =================="
}

function Get-PackagesList($source, $update) {
    $filter = { $_.Source -eq $source -and ($update ? $_.IsUpdateAvailable : -not $_.IsUpdateAvailable) }
    $props = @(
        @{ Name = 'Name'; Expression = { $_.Name } },
        @{ Name = 'Id'; Expression = { $_.Id } },
        @{ Name = 'Version'; Expression = { $_.InstalledVersion } }
    )
    if ($update) { $props += @{ Name = 'New Version'; Expression = { $_.AvailableVersions[0] } } }
    if (($IncludeVirusTotalLink -or $UpdateVirusTotalLink) -and $source -eq "winget") {
        $props += @{ Name = 'VirusTotalLink'; Expression = { Get-VirusTotalLink $_.Id } }
    }
    $packages | Where-Object $filter | Select-Object $props
}

$wingetUpdateGroup = Get-PackagesList "winget" $true
$wingetGroup = Get-PackagesList "winget" $false
$msstoreUpdateGroup = Get-PackagesList "msstore" $true
$msstoreGroup = Get-PackagesList "msstore" $false
$emptyUpdateGroup = Get-PackagesList $null $true
$emptyGroup = Get-PackagesList $null $false

$groups = @(
    @{ Label = "Winget Updateable Installed Packages"; Data = $wingetUpdateGroup },
    @{ Label = "Winget Installed Packages"; Data = $wingetGroup },
    @{ Label = "Store Updateable Installed Packages"; Data = $msstoreUpdateGroup },
    @{ Label = "Store Installed Packages"; Data = $msstoreGroup },
    @{ Label = "No Source Updateable Installed Packages"; Data = $emptyUpdateGroup },
    @{ Label = "No Source Installed Packages"; Data = $emptyGroup }
)

foreach ($g in $groups) {
    if ($g.Data.Count) {
        Write-Host
        Write-Host $g.Label -ForegroundColor Cyan
        $g.Data | Format-Table | Out-Host
    }
}

if ($ExportMarkdown) {
    $md = @()
    $md += "## Winget Packages"
    $md += "| Name | Version | Id | VirusTotalLink |"
    $md += "|------|---------|----|----------------|"
    $wingetGroup | ForEach-Object { $md += "| $($_.Name) | $($_.Version) | $($_.Id) | $($_.VirusTotalLink) |" }

    $md += ""
    $md += "## MSStore Packages"
    $md += "| Name | Version | Id |"
    $md += "|------|---------|----|"
    $msstoreGroup | ForEach-Object { $md += "| $($_.Name) | $($_.Version) | $($_.Id) |" }

    $md += ""
    $md += "## No Source Packages"
    $md += "| Name | Version | Id |"
    $md += "|------|---------|----|"
    $emptyGroup | ForEach-Object { $md += "| $($_.Name) | $($_.Version) | $($_.Id) |" }

    $md -join "`n" | Out-File -Encoding UTF8 "WingetListReport.md"
    Write-Host "Markdown report saved as WingetListReport.md"
}

if ($ExportHtml) {
    $html = @()
    $html += "<html><head><title>WingetList Report</title><style>body{font-family:sans-serif;}table{border-collapse:collapse;width:100%;margin-bottom:20px;}th,td{border:1px solid #ccc;padding:6px;}th{background:#eee;}</style></head><body>"
    $html += "<h2>Winget Packages</h2>"
    $html += "<table><tr><th>Name</th><th>Version</th><th>Id</th><th>VirusTotalLink</th></tr>"
    foreach ($pkg in $wingetGroup) {
        $vt = $pkg.VirusTotalLink
        if ($vt -and $vt -like "https://www.virustotal.com*") {
            $vt = "<a href='$vt' target='_blank'>VirusTotal</a>"
        }
        $html += "<tr><td>$($pkg.Name)</td><td>$($pkg.Version)</td><td>$($pkg.Id)</td><td>$vt</td></tr>"
    }
    $html += "</table>"

    $html += "<h2>MSStore Packages</h2>"
    $html += "<table><tr><th>Name</th><th>Version</th><th>Id</th></tr>"
    foreach ($pkg in $msstoreGroup) {
        $html += "<tr><td>$($pkg.Name)</td><td>$($pkg.Version)</td><td>$($pkg.Id)</td></tr>"
    }
    $html += "</table>"

    $html += "<h2>No Source Packages</h2>"
    $html += "<table><tr><th>Name</th><th>Version</th><th>Id</th></tr>"
    foreach ($pkg in $emptyGroup) {
        $html += "<tr><td>$($pkg.Name)</td><td>$($pkg.Version)</td><td>$($pkg.Id)</td></tr>"
    }
    $html += "</table>"

    $html += "</body></html>"
    $html -join "`n" | Out-File -Encoding UTF8 "WingetListReport.html"
    Write-Host "HTML report saved as WingetListReport.html"
}

if ($UpdateVirusTotalLink) {
    $checkmark = [char]0x2705

    # Load cache if it exists
    $cache = @{}
    if (Test-Path $shacacheFile) {
        $raw = Get-Content $shacacheFile | ConvertFrom-Json
        # Convert PSCustomObject to hashtable
        foreach ($entry in $raw.PSObject.Properties) {
            $cache[$entry.Name] = $entry.Value
        }
    }

    # Prepare a hashtable to store SHA results
    $shaTable = @{}

    # Only process winget packages
    $wingetPkgs = $packages | Where-Object { $_.Source -eq 'winget' }

    # Determine which packages need to be updated (not in cache or version changed)
    $toFetch = @()
    foreach ($pkg in $wingetPkgs) {
        $id = $pkg.Id
        $ver = $pkg.InstalledVersion
        if (-not $cache.ContainsKey($id) -or $cache[$id].Version -ne $ver) {
            $toFetch += $pkg
        }
        else {
            $shaTable[$id] = $cache[$id].SHA
        }
    }

    $tofetchcount = $toFetch.Count

    # Parallel fetch SHA256 for only needed packages
    if ($tofetchcount) {
        Write-Host "Processing $($tofetchcount) package(s)..." -ForegroundColor Cyan
  
        foreach ($pkg in $toFetch) {
            $id = $pkg.Id
            $ver = $pkg.InstalledVersion
            $sha = ""
            $info = winget show --id $id 2>$null
            $shaLine = ($info | Select-String 'Installer SHA256:').Line
            if ($shaLine) {
                $sha = $shaLine -replace '.*Installer SHA256:\s*', ''
            }
            $shaTable[$id] = $sha
            $cache[$id] = @{ Version = $ver; SHA = $sha }
            Write-Host "$checkmark $id " -ForegroundColor Green -NoNewline
            Write-Host "$ver" -ForegroundColor White
        }
  
        # Save updated cache
        $cache | ConvertTo-Json | Set-Content $shacacheFile
    }
  
    # Output the results as a table
    if ($shaTable.Count -and $tofetchcount) {
        Write-Host "$tofetchcount Packages updated in the cache." -ForegroundColor Green
    }
    elseif ($shaTable.Count -and -not $tofetchcount) {
        Write-Host "`nNo package updates necessary in the cache." -ForegroundColor White
    }
    else {
        Write-Host "No SHA256 hashes found." -ForegroundColor Red
    }
}