private/Write-WtwUpdateNotice.ps1
|
function Read-WtwJsonFile { <# .SYNOPSIS Read a JSON file, returning $null instead of throwing. .DESCRIPTION Used by best-effort local caches, where a missing, truncated, or concurrently rewritten file must behave exactly like "no data yet". #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Path ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { return $null } } function ConvertTo-WtwUtcDate { <# .SYNOPSIS Parse a round-trip ("O") timestamp into UTC, returning $null when the value is missing or malformed. #> [CmdletBinding()] param( [AllowNull()] [object] $Value ) $text = [string]$Value if ([string]::IsNullOrWhiteSpace($text)) { return $null } $parsed = [DateTime]::MinValue $parsedOk = [DateTime]::TryParse( $text, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::AdjustToUniversal, [ref] $parsed ) if (-not $parsedOk -or $parsed -eq [DateTime]::MinValue) { return $null } return $parsed.ToUniversalTime() } function Get-WtwShellSessionKey { <# .SYNOPSIS Identify the shell session a wtw command was launched from. .DESCRIPTION The zsh/bash integration runs a fresh pwsh per command, so $PID changes on every invocation and cannot mark "already told this session". Those wrappers export WTW_SHELL_SESSION with the calling shell's PID, which is stable for the life of that terminal. In PowerShell, wtw runs in-process and $PID already identifies the session. #> [CmdletBinding()] param() $shellSession = [string]$env:WTW_SHELL_SESSION if (-not [string]::IsNullOrWhiteSpace($shellSession)) { return "shell-$($shellSession.Trim())" } return "pwsh-$PID" } function Save-WtwUpdateNoticeState { <# .SYNOPSIS Persist the local update-notice bookkeeping file. .DESCRIPTION Holds the last background-refresh timestamp and the shell sessions that have already seen a notice. Sessions older than the retention window are dropped so the file cannot grow without bound. Writing is best effort: a read-only or full HOME must not break a wtw command. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Path, [AllowNull()] [object] $State, [DateTime] $LastRefreshSpawnUtc = [DateTime]::MinValue, [string] $NotifiedSession, [string] $NotifiedVersion, [TimeSpan] $SessionRetention = [TimeSpan]::FromDays(7), [int] $MaxSessions = 100 ) try { $now = [DateTime]::UtcNow $lastSpawn = if ($LastRefreshSpawnUtc -ne [DateTime]::MinValue) { $LastRefreshSpawnUtc } else { ConvertTo-WtwUtcDate -Value (Get-WtwPropertyValue -Object $State -Name 'LastRefreshSpawnUtc') } $sessions = [System.Collections.Generic.List[object]]::new() foreach ($entry in @(Get-WtwPropertyValue -Object $State -Name 'NotifiedSessions' -DefaultValue @())) { if ($null -eq $entry) { continue } $session = [string](Get-WtwPropertyValue -Object $entry -Name 'Session' -DefaultValue '') if ([string]::IsNullOrWhiteSpace($session)) { continue } if (-not [string]::IsNullOrWhiteSpace($NotifiedSession) -and $session -eq $NotifiedSession) { continue } $at = ConvertTo-WtwUtcDate -Value (Get-WtwPropertyValue -Object $entry -Name 'AtUtc') if ($null -eq $at -or ($now - $at) -ge $SessionRetention) { continue } $sessions.Add([ordered]@{ Session = $session Version = [string](Get-WtwPropertyValue -Object $entry -Name 'Version' -DefaultValue '') AtUtc = $at.ToString('O') }) } if (-not [string]::IsNullOrWhiteSpace($NotifiedSession)) { $sessions.Add([ordered]@{ Session = $NotifiedSession Version = $NotifiedVersion AtUtc = $now.ToString('O') }) } if ($sessions.Count -gt $MaxSessions) { $sessions = [System.Collections.Generic.List[object]](@($sessions | Select-Object -Last $MaxSessions)) } $directory = Split-Path -Parent $Path New-Item -ItemType Directory -Path $directory -Force -ErrorAction Stop | Out-Null [ordered]@{ LastRefreshSpawnUtc = if ($null -ne $lastSpawn) { $lastSpawn.ToString('O') } else { $null } NotifiedSessions = @($sessions) } | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $Path -Encoding utf8 -ErrorAction Stop } catch { # Bookkeeping is optional; at worst the notice repeats. } } function Start-WtwUpdateCheck { <# .SYNOPSIS Refresh the Gallery version cache in a detached background process. .DESCRIPTION The foreground command never waits on the network. When the cache is stale this starts an independent pwsh that writes the cache and exits; the result is used by the next wtw command. A cooldown stamp keeps rapid successive commands from starting more than one refresh. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $CachePath, [Parameter(Mandatory)] [string] $StatePath, [TimeSpan] $Cooldown = [TimeSpan]::FromMinutes(10) ) try { $manifestPath = Join-Path $script:WtwModuleRoot 'wtw.psd1' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { return } $pwshPath = [Environment]::ProcessPath if ([string]::IsNullOrWhiteSpace($pwshPath) -or -not (Test-Path -LiteralPath $pwshPath -PathType Leaf)) { return } $now = [DateTime]::UtcNow $state = Read-WtwJsonFile -Path $StatePath $lastSpawn = ConvertTo-WtwUtcDate -Value (Get-WtwPropertyValue -Object $state -Name 'LastRefreshSpawnUtc') if ($null -ne $lastSpawn -and ($now - $lastSpawn) -lt $Cooldown) { return } Save-WtwUpdateNoticeState -Path $StatePath -State $state -LastRefreshSpawnUtc $now $escapedManifest = $manifestPath.Replace("'", "''") $escapedCache = $CachePath.Replace("'", "''") $command = "Import-Module '$escapedManifest' -DisableNameChecking; " + "Get-WtwUpdateStatus -Force -CachePath '$escapedCache' | Out-Null" $startInfo = [Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $pwshPath foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-Command', $command)) { $startInfo.ArgumentList.Add($argument) } $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true # The child prints nothing; capturing its streams keeps any unexpected # output off the user's terminal instead of interleaving with wtw. $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true [Diagnostics.Process]::Start($startInfo) | Out-Null } catch { # A background update check is optional; never surface its failures. } } function Write-WtwUpdateNotice { <# .SYNOPSIS Print a short "a newer wtw is available" hint, at most once per shell session. .DESCRIPTION Reads only the locally cached PowerShell Gallery result, so it never waits on the network. When that cache is stale it starts a detached background refresh and prints nothing on this run; the next command uses the fresh result. Every failure mode - no cache, no network, an unreadable state file, a read-only HOME - is silent. The hint is skipped when output is redirected, which covers both CI and the wtw subcommands whose stdout the shell wrappers parse (go, __resolve, __aliases), and when WTW_NO_UPDATE_NOTICE is set. #> [CmdletBinding()] param( [string] $CachePath = (Join-Path $HOME '.wtw/update-check.json'), [string] $StatePath = (Join-Path $HOME '.wtw/update-notice.json'), [TimeSpan] $CacheTtl = [TimeSpan]::FromHours(24), [switch] $Force ) try { if (-not $Force) { $optOut = [string]$env:WTW_NO_UPDATE_NOTICE if (-not [string]::IsNullOrWhiteSpace($optOut) -and $optOut -notin @('0', 'false', 'no')) { return } if (-not [string]::IsNullOrWhiteSpace([string]$env:CI)) { return } if ([Console]::IsOutputRedirected) { return } } $manifestPath = Join-Path $script:WtwModuleRoot 'wtw.psd1' if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { return } $currentVersion = $null $currentText = [string](Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion if (-not [version]::TryParse($currentText, [ref] $currentVersion)) { return } $cacheFile = [IO.Path]::GetFullPath($CachePath) $stateFile = [IO.Path]::GetFullPath($StatePath) $cache = Read-WtwJsonFile -Path $cacheFile $checkedAt = ConvertTo-WtwUtcDate -Value (Get-WtwPropertyValue -Object $cache -Name 'CheckedAtUtc') if ($null -eq $checkedAt -or ([DateTime]::UtcNow - $checkedAt) -ge $CacheTtl) { Start-WtwUpdateCheck -CachePath $cacheFile -StatePath $stateFile return } if ([string](Get-WtwPropertyValue -Object $cache -Name 'Status' -DefaultValue '') -ne 'Available') { return } $latestVersion = $null $latestText = [string](Get-WtwPropertyValue -Object $cache -Name 'LatestVersion' -DefaultValue '') if (-not [version]::TryParse($latestText, [ref] $latestVersion)) { return } if ($latestVersion -le $currentVersion) { return } $sessionKey = Get-WtwShellSessionKey $state = Read-WtwJsonFile -Path $stateFile foreach ($entry in @(Get-WtwPropertyValue -Object $state -Name 'NotifiedSessions' -DefaultValue @())) { if ($null -eq $entry) { continue } if ([string](Get-WtwPropertyValue -Object $entry -Name 'Session' -DefaultValue '') -ne $sessionKey) { continue } if ([string](Get-WtwPropertyValue -Object $entry -Name 'Version' -DefaultValue '') -eq $latestVersion.ToString()) { return } } Save-WtwUpdateNoticeState -Path $stateFile -State $state ` -NotifiedSession $sessionKey -NotifiedVersion $latestVersion.ToString() Write-Host '' Write-Host (" wtw {0} is available (you have {1})." -f $latestVersion, $currentVersion) -ForegroundColor Cyan Write-Host ' Update: wtw install (or: Update-Module wtw -Scope CurrentUser)' -ForegroundColor DarkGray } catch { # An update hint must never slow down, interrupt, or fail a wtw command. } } |