Private/Tools/Install-WingetPackageSafe.ps1
|
function Install-WingetPackageSafe { <# .SYNOPSIS Installs a winget package once per session and patches PATH so the exe is usable immediately. .DESCRIPTION The shared Install half of the Enable-* tool-enabler pattern. It is a no-op when the package's exe is already on PATH; otherwise it installs the package via Microsoft's first-party Microsoft.WinGet.Client module (Install-WinGetPackage, loaded on demand through Import-ModuleSafe), then patches the *current session's* $env:Path with the directory the package lands in (winget only updates the registry user PATH), and finally re-checks for the exe. If the exe still isn't resolvable, it emits a Write-Warning that includes the install result Status. The already-installed short-circuit runs *before* the module is loaded, so once a tool is present, profile startup never imports Microsoft.WinGet.Client — only a first-time (or missing) install pays that cost. Success is judged by the post-install Get-Command re-check (ground truth), with the result's Status surfaced in the warning for diagnostics. Nothing here throws, so profile startup continues even when an install fails. .PARAMETER Id The winget package id to install (passed as -Id), e.g. 'ajeetdsouza.zoxide'. .PARAMETER Exe The executable name to probe with Get-Command, e.g. 'zoxide.exe'. Both the already-installed short-circuit and the post-install success check key off this. .PARAMETER PathDir The directory the package's exe lands in, appended to this session's $env:Path if not already present. winget portables use $env:LOCALAPPDATA\Microsoft\WinGet\Links; installer packages (e.g. oh-my-posh) use their own program dir. .PARAMETER Scope Optional install scope: 'user' or 'machine'. Maps to Install-WinGetPackage's -Scope (User / System). Omit to let winget choose (its own default). .PARAMETER CallerName The enabler function's name, used to prefix the diagnostic warning so the failing tool is identifiable (e.g. 'Enable-Zoxide'). .EXAMPLE Install-WingetPackageSafe -Id 'ajeetdsouza.zoxide' -Exe 'zoxide.exe' ` -PathDir (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Links') -CallerName 'Enable-Zoxide' .EXAMPLE Install-WingetPackageSafe -Id 'JanDeDobbeleer.OhMyPosh' -Exe 'oh-my-posh.exe' ` -PathDir (Join-Path $env:LOCALAPPDATA 'Programs\oh-my-posh\bin') ` -Scope user -CallerName 'Enable-OhMyPosh' .NOTES Call this from inside an Invoke-Step "Install" { } block in an Enable-* function; the matching Initialize step (guarded by Get-Command <exe>) degrades gracefully if the install didn't take. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string]$Id, [Parameter(Mandatory, Position = 1)] [string]$Exe, [Parameter(Mandatory, Position = 2)] [string]$PathDir, [Parameter()] [ValidateSet('user', 'machine')] [string]$Scope, [Parameter(Mandatory)] [string]$CallerName ) # Short-circuit BEFORE loading the module: an already-installed tool costs nothing at startup. if (Get-Command $Exe -ErrorAction SilentlyContinue) { return } Import-ModuleSafe Microsoft.WinGet.Client if (-not (Get-Command Install-WinGetPackage -ErrorAction SilentlyContinue)) { Write-Warning "${CallerName}: Microsoft.WinGet.Client is unavailable; cannot install $Id." return } $installArgs = @{ Id = $Id; Source = 'winget'; MatchOption = 'Equals'; Mode = 'Silent' } if ($PSBoundParameters.ContainsKey('Scope')) { $installArgs.Scope = if ($Scope -eq 'machine') { 'System' } else { 'User' } } # Suppress the cmdlet's progress so it doesn't tear Invoke-Step's live Spectre spinner. $result = $null $prevProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' try { $result = Install-WinGetPackage @installArgs } catch { Write-Warning "${CallerName}: Install-WinGetPackage of $Id threw: $($_.Exception.Message)" } finally { $ProgressPreference = $prevProgress } # winget only updates the *user* PATH (registry) — patch this session's PATH so the Initialize # substep can resolve the exe immediately. # Split on ';' and compare exactly (case-insensitive -notcontains) so $PathDir is matched # literally — avoids -like treating any '[' / '*' in the path as a wildcard pattern. if (($env:Path -split ';') -notcontains $PathDir) { $env:Path += ";$PathDir" } # Ground truth beats the result code: if the exe still isn't resolvable, it didn't take. if (-not (Get-Command $Exe -ErrorAction SilentlyContinue)) { Write-Warning "${CallerName}: install of $Id did not produce $Exe on PATH. Status=$($result.Status) ErrorCode=$($result.InstallerErrorCode)" } } |