lib/Winget.ps1

function Get-WingetPackagesConfig {
    <#
    .SYNOPSIS
        Reads the WingetPackages array from the same config file used by
        Get-PathsConfig. Returns an empty array when the key is absent or
        the file doesn't exist.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string] $ConfigFile = $script:ConfigFilePath
    )

    if (-not $ConfigFile -or -not (Test-Path -Path $ConfigFile -PathType Leaf)) {
        return @()
    }

    try {
        $raw    = Get-Content -Path $ConfigFile -Raw -Encoding UTF8
        $parsed = $raw | ConvertFrom-Json -ErrorAction Stop
    }
    catch {
        throw "Failed to parse '$ConfigFile': $($_.Exception.Message)"
    }

    if ($null -eq $parsed) { return @() }
    if (-not $parsed.PSObject.Properties['WingetPackages']) { return @() }

    @($parsed.WingetPackages | Where-Object { $_ })
}

function Initialize-Winget {
    <#
    .SYNOPSIS
        Ensures winget is available on PATH. If it is missing, attempts to
        bootstrap it via the Microsoft.WinGet.Client PowerShell module.

        Throws with a clear "install manually" message if the bootstrap
        fails. Under -WhatIf, just notes that the bootstrap would happen
        without actually mutating the system.
    #>

    [CmdletBinding()]
    param()

    if (Get-Command winget -ErrorAction SilentlyContinue) {
        return
    }

    if ($WhatIfPreference) {
        Write-Status -Level Info -Message ' [INFO] winget is not installed; would bootstrap on a real run.'
        return
    }

    Write-Status -Level Info -Message ' [INFO] winget not found; bootstrapping via Microsoft.WinGet.Client...'

    # Step 1: install the helper module from PSGallery if we don't already have it.
    if (-not (Get-Module -ListAvailable -Name Microsoft.WinGet.Client)) {
        # The PSGallery prompt asking "are you sure you want to install from
        # an untrusted repository" isn't always bypassed by -Force, so flip
        # the policy to Trusted just for this install and restore it after.
        $repo       = Get-PSRepository -Name PSGallery -ErrorAction SilentlyContinue
        $prevPolicy = if ($repo) { $repo.InstallationPolicy } else { $null }
        try {
            if ($prevPolicy -and $prevPolicy -ne 'Trusted') {
                Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction Stop
            }
            Install-Module -Name Microsoft.WinGet.Client -Force -Scope CurrentUser -AllowClobber -Confirm:$false -ErrorAction Stop
        }
        catch {
            throw "Failed to install Microsoft.WinGet.Client module: $($_.Exception.Message). Try ``Install-Module -Name PowerShellGet -Force`` first, or install winget manually from https://aka.ms/getwinget."
        }
        finally {
            if ($prevPolicy -and $prevPolicy -ne 'Trusted') {
                Set-PSRepository -Name PSGallery -InstallationPolicy $prevPolicy -ErrorAction SilentlyContinue
            }
        }
    }

    Import-Module Microsoft.WinGet.Client -ErrorAction Stop

    # Step 2: install or repair winget itself.
    try {
        Repair-WinGetPackageManager -Latest -Force -ErrorAction Stop | Out-Null
    }
    catch {
        throw "Failed to bootstrap winget: $($_.Exception.Message). Install winget manually from https://aka.ms/getwinget."
    }

    if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
        throw "winget bootstrap appeared to succeed but the command is still not on PATH. Restart your terminal or check the App Installer state in the Microsoft Store."
    }

    Write-Status -Level Info -Message ' [INFO] winget bootstrapped.'
}

function Reset-EnvPath {
    <#
    .SYNOPSIS
        Refresh the current process's environment from the registry so
        newly-installed binaries are visible without a shell restart.

        Copies every Machine-scope env var into the process, then
        every User-scope env var (User overrides Machine, matching the
        OS's own resolution order). PATH is special-cased: machine PATH
        + user PATH are concatenated (not overridden) so user-scope
        additions don't wipe out the system PATH. REG_EXPAND_SZ
        placeholders like "%NVM_HOME%" are then expanded against the
        freshly-loaded env, which is needed for installers that set
        their own helper vars (e.g. nvm-windows: NVM_HOME, NVM_SYMLINK).
    #>

    [CmdletBinding()]
    param()

    # Step 1: pull non-PATH env vars from machine, then user.
    foreach ($scope in 'Machine','User') {
        $vars = [System.Environment]::GetEnvironmentVariables($scope)
        foreach ($key in $vars.Keys) {
            if ([string]::Equals($key, 'PATH', [System.StringComparison]::OrdinalIgnoreCase)) { continue }
            [System.Environment]::SetEnvironmentVariable($key, [string]$vars[$key], 'Process')
        }
    }

    # Step 2: rebuild PATH as machine+user concatenation, then expand
    # placeholders against the env we just loaded.
    $machinePath = [System.Environment]::GetEnvironmentVariable('PATH', 'Machine')
    $userPath    = [System.Environment]::GetEnvironmentVariable('PATH', 'User')
    $parts       = @($machinePath, $userPath) | Where-Object { $_ }
    $combined    = ($parts -join ';').TrimEnd(';')
    $env:PATH    = [System.Environment]::ExpandEnvironmentVariables($combined)
}

function Set-WingetProgressBarDisabled {
    <#
    .SYNOPSIS
        Writes "visual.progressBar = disabled" into winget's user-scope
        settings.json so winget's own animated spinner stops filling
        captured logs with carriage-return frames. This is the only
        reliable suppression in CI - --disable-interactivity does not
        fully kill the progress UI in Actions' pwsh steps (see
        microsoft/winget-cli#3300).

        Idempotent. Preserves any other settings already in the file.
    #>

    [CmdletBinding()]
    param()

    if (-not $env:LOCALAPPDATA) { return }
    $dir = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState'
    if (-not (Test-Path -LiteralPath $dir)) {
        # winget creates this directory on first invocation. We need it
        # to exist so we can drop our settings file in advance.
        $null = New-Item -ItemType Directory -Path $dir -Force -ErrorAction SilentlyContinue
        if (-not (Test-Path -LiteralPath $dir)) { return }
    }
    $path = Join-Path $dir 'settings.json'

    $settings = $null
    if (Test-Path -LiteralPath $path) {
        try {
            $settings = Get-Content -LiteralPath $path -Raw -Encoding UTF8 |
                ConvertFrom-Json -ErrorAction Stop
        }
        catch {
            $settings = $null
        }
    }
    if ($null -eq $settings) {
        $settings = [pscustomobject]@{
            '$schema' = 'https://aka.ms/winget-settings.schema.json'
        }
    }
    if (-not $settings.PSObject.Properties['visual']) {
        $settings | Add-Member -NotePropertyName 'visual' -NotePropertyValue ([pscustomobject]@{}) -Force
    }
    $settings.visual | Add-Member -NotePropertyName 'progressBar' -NotePropertyValue 'disabled' -Force

    $json = $settings | ConvertTo-Json -Depth 10
    Set-Content -LiteralPath $path -Value $json -Encoding UTF8
}

function Get-WingetStatePath {
    <#
    .SYNOPSIS
        Returns the path to the script's local winget-state JSON file
        (under %LocalAppData%\Initialize-DeveloperMachine), or $null if
        $env:LOCALAPPDATA isn't available.

        The state file tracks packages we've installed but winget can't
        detect afterwards (e.g. Microsoft.DotNet.Framework.DeveloperPack_4
        installs reference assemblies + targeting pack but doesn't
        register in a way `winget list -e` can match against). Without
        this tracking the script would reinstall those packages on
        every run, wasting minutes for the user and breaking
        idempotency in CI.
    #>

    [CmdletBinding()]
    param()

    if (-not $env:LOCALAPPDATA) { return $null }
    Join-Path $env:LOCALAPPDATA 'Initialize-DeveloperMachine\winget-state.json'
}

function Get-InstalledUndetectablePackages {
    <#
    .SYNOPSIS
        Returns the list of package IDs the script has previously
        installed but winget couldn't detect afterwards. Empty array
        when the state file is missing or unreadable.
    #>

    [CmdletBinding()]
    param()

    $path = Get-WingetStatePath
    if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return @() }
    try {
        $j = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
        if ($null -eq $j -or -not $j.PSObject.Properties['undetectable']) { return @() }
        @($j.undetectable | Where-Object { $_ })
    }
    catch {
        @()
    }
}

function Add-InstalledUndetectablePackage {
    <#
    .SYNOPSIS
        Records that we installed $PackageId but winget can't detect it
        afterwards, so future runs skip it instead of reinstalling.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string] $PackageId
    )

    $path = Get-WingetStatePath
    if (-not $path) { return }

    $existing = @(Get-InstalledUndetectablePackages)
    if ($PackageId -in $existing) { return }

    $dir = Split-Path -Parent $path
    [void][System.IO.Directory]::CreateDirectory($dir)

    $merged = @($existing) + $PackageId
    @{ undetectable = $merged } |
        ConvertTo-Json -Depth 3 |
        Set-Content -LiteralPath $path -Encoding UTF8
}

function Install-WingetPackages {
    <#
    .SYNOPSIS
        Installs each winget package in $Packages, skipping anything
        winget already considers installed (either via its own package
        database or because a non-winget installer left a newer version
        on disk - the WINGET_E_PACKAGE_ALREADY_INSTALLED and
        WINGET_E_NO_APPLICABLE_INSTALLER exit codes are treated as
        success/skip rather than failure).

        Refreshes $env:PATH at the end via Reset-EnvPath so freshly
        installed binaries are visible to subsequent steps in the same
        run.

        Self-skips when winget isn't on PATH (e.g. before
        Initialize-Winget has finished bootstrapping it, or under
        -WhatIf when the bootstrap is itself a no-op).
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]] $Packages
    )

    if ($Packages.Count -eq 0) {
        return
    }

    if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
        Write-Status -Level Skip -Message ' [SKIP] winget is not on PATH; package installs would happen on a real run.'
        return
    }

    # See https://learn.microsoft.com/windows/package-manager/winget/returnCodes
    $alreadyPresent = @(
        0x8A150061,  # WINGET_E_NO_APPLICABLE_INSTALLER (newer/equal already present)
        0x8A150064   # WINGET_E_PACKAGE_ALREADY_INSTALLED
    )

    # In a CI / non-interactive shell, winget's own progress spinner spams
    # the captured log with carriage-return frames. Two layers of defense:
    # 1. --disable-interactivity stops the interactive prompts.
    # 2. settings.json visual.progressBar = disabled stops the progress
    # spinner that --disable-interactivity does not fully suppress
    # (microsoft/winget-cli#3300).
    $nonInteractive = `
        $env:CI -or `
        $env:GITHUB_ACTIONS -or `
        $env:TF_BUILD -or `
        [Console]::IsOutputRedirected
    $extraArgs = @()
    if ($nonInteractive) {
        $extraArgs += '--disable-interactivity'
        Set-WingetProgressBarDisabled
    }

    $undetectables = @(Get-InstalledUndetectablePackages)
    $listArgs = @('--accept-source-agreements') + $extraArgs

    # Warm up winget once. The very first winget call of a session
    # prints the source-agreement boilerplate ("The msstore source
    # requires that you view the following agreements...") even when
    # --accept-source-agreements is passed - the flag accepts the
    # agreement but doesn't suppress the informational text. Doing
    # one quiet call here so subsequent per-package list/install
    # output isn't preceded by a wall of agreement text.
    $null = & winget list --accept-source-agreements @extraArgs *>&1 2>$null

    foreach ($pkg in $Packages) {
        # Pre-install check: only the exit code matters. winget's
        # stdout for the "not installed" case is the boilerplate
        # 'No installed package found matching input criteria.', which
        # is redundant given we follow up with '[INFO] Installing X...'
        # whenever it's missing. Discard the output.
        $null = & winget list --id $pkg -e @listArgs 2>&1
        $listRc = $LASTEXITCODE

        if ($listRc -eq 0) {
            Write-Status -Level Info -Message " [INFO] $pkg is already installed."
            continue
        }

        # Some packages (notably Microsoft.DotNet.Framework.DeveloperPack_4)
        # install fine but don't register in a way `winget list -e` can
        # match against. We tracked those after their first install in
        # the local state file - skip them here so we don't reinstall
        # on every run.
        if ($pkg -in $undetectables) {
            Write-Status -Level Info -Message " [INFO] $pkg is already installed (tracked locally; winget can't detect this package post-install)."
            continue
        }

        if ($PSCmdlet.ShouldProcess($pkg, 'winget install')) {
            Write-Status -Level Info -Message " [INFO] Installing $pkg..."

            # winget's per-install chatter ("Found X", "Downloading...",
            # "Successfully installed", legal boilerplate) is suppressed by
            # default and streamed indented under -Verbose. See
            # Format-ToolOutput in lib/Status.ps1.
            winget install --id $pkg -e `
                --accept-package-agreements `
                --accept-source-agreements `
                --silent `
                @extraArgs 2>&1 |
                Format-ToolOutput -SkipPattern 'licensed to you by its owner','Microsoft is not responsible'
            $rc = $LASTEXITCODE

            if ($rc -eq 0) {
                # Verify winget can actually see the install. If not,
                # remember this package locally so the next run doesn't
                # try to reinstall - and dump winget's response as
                # [DEBUG] so the user can see why winget didn't match
                # (typically "No installed package found matching
                # input criteria" even though the install succeeded).
                $verifyOutput = & winget list --id $pkg -e @listArgs 2>&1
                $verifyRc     = $LASTEXITCODE
                if ($verifyRc -ne 0) {
                    $verifyRcHex = '0x{0:X8}' -f $verifyRc
                    Write-Status -Level Warn -Message (" [WARN] {0} installed but winget can't detect it (exit code {1}); recording in local state to skip on next run." -f $pkg, $verifyRcHex)
                    $verifyLines = @($verifyOutput | ForEach-Object { ([string]$_).TrimEnd() } | Where-Object { $_ })
                    foreach ($line in $verifyLines) {
                        Write-Status -Level Debug -Message " [DEBUG] winget list --id $pkg -e: $line"
                    }
                    if ($verifyLines.Count -eq 0) {
                        Write-Status -Level Debug -Message " [DEBUG] winget list --id $pkg -e produced no output."
                    }

                    # Investigation: did the installer register the
                    # package under a different ID/Name than winget's
                    # catalog uses, or does winget genuinely not track
                    # it at all? Pull every installed package and grep
                    # for tokens of the failing ID + a couple of known
                    # OS-level uninstall registry keys.
                    #
                    # Wrapped in try/catch so a diagnostic-side bug
                    # can never break the install path - the
                    # local-state tracking below must run regardless.
                    try {
                        $tokens = @($pkg -split '\.' | Where-Object { $_.Length -ge 4 })
                        if ($tokens.Count -gt 0) {
                            $pattern = ($tokens | ForEach-Object { [regex]::Escape($_) }) -join '|'
                            Write-Status -Level Debug -Message " [DEBUG] Searching all installed winget packages for tokens: $pattern"
                            $allInstalled = & winget list @listArgs 2>&1
                            $matches = @($allInstalled | ForEach-Object { ([string]$_).TrimEnd() } |
                                Where-Object { $_ -and $_ -match $pattern })
                            if ($matches.Count -gt 0) {
                                foreach ($m in $matches) {
                                    Write-Status -Level Debug -Message " [DEBUG] $m"
                                }
                            }
                            else {
                                Write-Status -Level Debug -Message ' [DEBUG] No matching lines in `winget list` output.'
                            }

                            Write-Status -Level Debug -Message " [DEBUG] Searching Windows uninstall registry for tokens: $pattern"
                            $uninstallRoots = @(
                                'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
                                'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall',
                                'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
                            )
                            $hits = foreach ($root in $uninstallRoots) {
                                if (Test-Path -Path $root) {
                                    Get-ChildItem -Path $root -ErrorAction SilentlyContinue |
                                        ForEach-Object {
                                            $p = Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue
                                            # Set-StrictMode is on, so we can't
                                            # touch a property that doesn't exist.
                                            # Many uninstall keys have no DisplayName.
                                            $dn = if ($p -and $p.PSObject.Properties['DisplayName']) {
                                                [string]$p.DisplayName
                                            } else { '' }
                                            $dv = if ($p -and $p.PSObject.Properties['DisplayVersion']) {
                                                [string]$p.DisplayVersion
                                            } else { '' }
                                            if ($dn -and $dn -match $pattern) {
                                                [pscustomobject]@{
                                                    Hive    = $root
                                                    Key     = $_.PSChildName
                                                    Name    = $dn
                                                    Version = $dv
                                                }
                                            }
                                        }
                                }
                            }
                            if ($hits) {
                                foreach ($h in $hits) {
                                    Write-Status -Level Debug -Message (" [DEBUG] uninstall: {0} = {1} {2}" -f $h.Key, $h.Name, $h.Version)
                                }
                            }
                            else {
                                Write-Status -Level Debug -Message ' [DEBUG] No matching DisplayName under HKLM/HKCU Uninstall.'
                            }
                        }
                    }
                    catch {
                        Write-Status -Level Debug -Message " [DEBUG] Diagnostic walk failed (non-fatal): $($_.Exception.Message)"
                    }

                    Add-InstalledUndetectablePackage -PackageId $pkg
                }
                Write-Status -Level Ok -Message " [INFO] $pkg installed."
            }
            elseif ($rc -in $alreadyPresent) {
                Write-Status -Level Info -Message (" [INFO] {0} already present (winget exit 0x{1:X8})." -f $pkg, $rc)
            }
            else {
                throw ("winget install {0} failed with exit code 0x{1:X8}." -f $pkg, $rc)
            }
        }
    }

    Reset-EnvPath
}