Private/Get-UpdateToolInventory.ps1

function Get-UpdateToolInventory {
    <#
        .SYNOPSIS
        Reports what this machine has of the tools Update-Everything drives.
 
        .DESCRIPTION
        One record per tool, with Name, Command, Present, Version, Owner and
        Copies.
 
        Every step already resolves its own tool and skips when it is absent, so
        this changes no behaviour. What it adds is a picture: on a first run the
        summary is a long column of skips, and nothing says whether that is a
        machine with little installed or a run that went wrong.
 
        Owner comes from Get-ToolInstallSource and answers who is responsible for
        updating a tool. Copies counts the distinct directories an executable of
        that name resolves from; more than one is worth knowing, because the
        version reported here is the one that runs and the others are updated by
        nobody.
 
        Directories rather than files, because PATHEXT resolves npm.cmd and npm
        from the same folder as two commands. That is one install, and reporting
        it as two would train people to ignore the warning.
 
        A tool that fails or hangs on its version argument reports Present with a
        null Version rather than failing the inventory. Knowing something is there
        is most of the value.
 
        .PARAMETER Catalogue
        The tools to look for. Defaults to the ones this module drives; a test
        passes its own. Each entry names the Command, the VersionArgument that
        makes it print its version (null to record presence only), and
        optionally Environment, a hashtable of variables set for that one call.
 
        .EXAMPLE
        Get-UpdateToolInventory | Where-Object Present
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [object[]] $Catalogue
    )

    if (-not $Catalogue) {
        # Name is what a person calls it; Command is what resolves on PATH.
        $Catalogue = @(
            @{ Name = 'winget';          Command = 'winget';  VersionArgument = '--version' }
            @{ Name = 'Chocolatey';      Command = 'choco';   VersionArgument = '--version' }
            @{ Name = 'Scoop';           Command = 'scoop';   VersionArgument = $null }
            @{ Name = 'PowerShell 7';    Command = 'pwsh';    VersionArgument = '--version' }
            @{ Name = 'Node.js';         Command = 'node';    VersionArgument = '--version' }
            @{ Name = 'npm';             Command = 'npm';     VersionArgument = '--version' }
            @{ Name = 'Deno';            Command = 'deno';    VersionArgument = '--version' }
            @{ Name = 'Bun';             Command = 'bun';     VersionArgument = '--version' }
            @{ Name = 'pnpm';            Command = 'pnpm';    VersionArgument = '--version' }
            @{ Name = 'Python launcher'; Command = 'py';      VersionArgument = '--version' }
            # pymanager prints its version at the head of help and nothing for --version.
            @{ Name = 'Python manager';  Command = 'pymanager'; VersionArgument = 'help' }
            @{ Name = 'uv';              Command = 'uv';      VersionArgument = '--version' }
            @{ Name = 'pip';             Command = 'pip';     VersionArgument = '--version' }
            @{ Name = 'pipx';            Command = 'pipx';    VersionArgument = '--version' }
            @{ Name = 'conda';           Command = 'conda';   VersionArgument = '--version' }
            @{ Name = '.NET SDK';        Command = 'dotnet';  VersionArgument = '--version' }
            @{ Name = 'rustup';          Command = 'rustup';  VersionArgument = '--version' }
            @{ Name = 'cargo-update';    Command = 'cargo-install-update'; VersionArgument = '--version' }
            @{ Name = 'Go';              Command = 'go';      VersionArgument = 'version' }
            @{ Name = 'gup';             Command = 'gup';     VersionArgument = 'version' }
            @{ Name = 'GitHub CLI';      Command = 'gh';      VersionArgument = '--version' }
            @{ Name = 'Azure CLI';       Command = 'az';      VersionArgument = $null }
            @{ Name = 'Google Cloud CLI'; Command = 'gcloud'; VersionArgument = $null }
            @{ Name = 'VS Code';         Command = 'code';    VersionArgument = $null }
            @{ Name = 'MiKTeX';          Command = 'miktex';  VersionArgument = '--version' }
            # wsl.exe writes UTF-16 or single-byte text depending on where it
            # runs; WSL_UTF8=1 makes it write UTF-8 everywhere.
            @{ Name = 'WSL';             Command = 'wsl';     VersionArgument = '--version'; Environment = @{ WSL_UTF8 = '1' } }
        )
    }

    foreach ($tool in $Catalogue) {
        $resolved = @(Get-Command $tool.Command -CommandType Application -ErrorAction SilentlyContinue)
        # First-occurrence order is PATH-resolution order, so the first entry is
        # the copy that actually runs. Dedupe case-insensitively: a Windows PATH
        # routinely carries the same directory in different casing across its
        # machine and user segments, and Select-Object -Unique is case-sensitive.
        $seen = [System.Collections.Generic.HashSet[string]]::new(
            [System.StringComparer]::OrdinalIgnoreCase)
        $places = @($resolved |
            ForEach-Object { Split-Path $_.Source -Parent } |
            Where-Object { $_ -and $seen.Add($_) })

        if (-not $resolved.Count) {
            [pscustomobject]@{
                Name    = $tool.Name
                Command = $tool.Command
                Present = $false
                Version = $null
                Owner   = $null
                Copies  = 0
                Places  = @()
            }
            continue
        }

        # The null version arguments are on purpose. scoop is a PowerShell shim
        # whose --version runs a git describe against its own repository; az,
        # gcloud and code each take seconds to answer. Presence is most of the
        # value, and none of these are worth the wait.
        $version = $null
        if ($tool.VersionArgument) {
            # A tool that needs a variable to answer plainly gets it for this
            # call only; whatever the variable held before comes back after.
            $previousEnvironment = @{}
            try {
                if ($tool.Environment) {
                    foreach ($name in @($tool.Environment.Keys)) {
                        $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name)
                        [Environment]::SetEnvironmentVariable($name, [string]$tool.Environment[$name])
                    }
                }
                $raw = & $tool.Command $tool.VersionArgument 2>&1
                $global:LASTEXITCODE = 0
                $version = @($raw | Out-String -Stream |
                    Where-Object { $_ -and $_.Trim() } |
                    ForEach-Object { $_.Trim() })[0]
            } catch {
                Write-Verbose "$($tool.Command) $($tool.VersionArgument) failed: $($_.Exception.Message)"
                $global:LASTEXITCODE = 0
            } finally {
                foreach ($name in @($previousEnvironment.Keys)) {
                    [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name])
                }
            }
        }

        [pscustomobject]@{
            Name    = $tool.Name
            Command = $tool.Command
            Present = $true
            Version = $version
            Owner   = Get-ToolInstallSource -Name $tool.Command
            Copies  = $places.Count
            Places  = $places
        }
    }
}