Private/UpdateService.ps1
|
# The one update service: Show-SLHelp, Invoke-SLUpdateCheck, Get-SLUpdateStatus # and the opt-in profile block all use it. # # One cache per user per machine (LocalApplicationData\SecondLine, or # SECONDLINE_CACHE_DIR), shared by every session and project: # update-cache.json lookup results, failure backoff, last notice shown (atomic writes) # update-cache.lock held during a lookup, so concurrent sessions don't repeat it # next-check.txt UTC ticks of the next due lookup; the profile block reads only this # notice.txt a pending notice for the next interactive shell (profile block) # # Lookups query only the PowerShell Gallery, only for SecondLine, stable releases by # default, with a short timeout and bounded retries. Nothing is ever installed. $script:SLUpdateDefaults = @{ Enabled = $true RefreshHours = 24 TimeoutSeconds = 3 Retries = 1 IncludePrerelease = $false FailureBackoffMinutes = 60 } $script:SLUpdateLimits = @{ RefreshHours = @(1, 720) TimeoutSeconds = @(1, 30) Retries = @(0, 3) FailureBackoffMinutes = @(1, 1440) } $script:SLCacheSchema = 1 # A lock older than this belongs to a process that died without cleaning up. $script:SLLockStaleMinutes = 2 # ---------------------------------------------------------------- settings -- # Problems with one Updates section, as strings. function Test-SLUpdateSettings { param($Settings, [string] $Label) if ($Settings -isnot [hashtable]) { return "$Label must be @{ RefreshHours = 24; ... }." } foreach ($key in $Settings.Keys) { $value = $Settings[$key] if (-not $script:SLUpdateDefaults.ContainsKey($key)) { "$Label has an unknown key '$key'. Use: $((@($script:SLUpdateDefaults.Keys) | Sort-Object) -join ', ')." } elseif ($key -in 'Enabled', 'IncludePrerelease') { if ($value -isnot [bool]) { "$Label.$key must be `$true or `$false." } } else { $range = $script:SLUpdateLimits[$key] $n = 0 if (-not [int]::TryParse("$value", [ref] $n) -or $n -lt $range[0] -or $n -gt $range[1]) { "$Label.$key must be a whole number from $($range[0]) to $($range[1])." } } } } # Effective update settings, each with its source. Precedence, lowest first: # defaults, the project chain (root to nearest), machine, user. Update notices are a # personal preference, so here the user has the last word. They never change a # project's approved version, which comes only from ModuleVersions. In CI, or with # SECONDLINE_NO_UPDATE_CHECK=1, checks are off. function Get-SLUpdateSettings { param([string] $Standard, [string] $Directory, [switch] $NoHostSettings) $layers = @([pscustomobject]@{ Source = 'default'; Data = @{ Updates = $script:SLUpdateDefaults } }) $layers += @(Get-SLProjectSettingsLayer -Standard $Standard -Directory $Directory -DevelopmentPath $script:SLDevelopmentStandard) foreach ($h in @(Get-SLHostSettingsLayer -NoHostSettings:$NoHostSettings)) { $layers += [pscustomobject]@{ Source = "$($h.Scope.ToLowerInvariant()) $($h.Path)"; Data = $h.Data } } foreach ($layer in $layers) { if (-not $layer.Data.ContainsKey('Updates')) { continue } $problems = @(Test-SLUpdateSettings -Settings $layer.Data.Updates -Label "Updates in $($layer.Source)") if ($problems.Count -gt 0) { throw ($problems -join ' ') } } $effective = Merge-SLSettingLayer -Layers $layers -Section 'Updates' $off = $null if ($env:SECONDLINE_NO_UPDATE_CHECK -eq '1') { $off = 'SECONDLINE_NO_UPDATE_CHECK=1' } elseif (Test-SLCIEnvironment) { $off = 'CI environment' } if ($off) { $prior = $effective['Enabled'] $effective['Enabled'] = [pscustomobject]@{ Name = 'Enabled'; Value = $false; Source = $off; Overridden = @([pscustomobject]@{ Value = $prior.Value; Source = $prior.Source }) } } return $effective } # Plain values (name -> value) from Get-SLUpdateSettings. function ConvertTo-SLUpdateOptions { param([hashtable] $Settings) $options = @{} foreach ($key in $Settings.Keys) { $options[$key] = $Settings[$key].Value } return $options } # ------------------------------------------------------------------- cache -- function Get-SLCacheDirectory { if ($env:SECONDLINE_CACHE_DIR) { return $env:SECONDLINE_CACHE_DIR } return (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'SecondLine') } # The cache, or an empty one if the file is missing, unreadable, or from another schema. function Read-SLUpdateCache { $empty = [pscustomobject]@{ SchemaVersion = $script:SLCacheSchema; Entries = [pscustomobject]@{}; NoticeShown = '' } $file = Join-Path (Get-SLCacheDirectory) 'update-cache.json' if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { return $empty } try { $cache = [IO.File]::ReadAllText($file) | ConvertFrom-Json -ErrorAction Stop if ($cache.SchemaVersion -ne $script:SLCacheSchema -or $null -eq $cache.Entries) { return $empty } if ($null -eq $cache.PSObject.Properties['NoticeShown']) { $cache | Add-Member NoticeShown '' } return $cache } catch { return $empty } } # Writes text to a temporary file next to Path, then swaps it in, so readers never see half a file. function Write-SLAtomicText { param([Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [AllowEmptyString()] [string] $Text) $dir = Split-Path $Path -Parent if (-not (Test-Path -LiteralPath $dir)) { [void] (New-Item -ItemType Directory -Path $dir -Force) } $temp = "$Path.$([guid]::NewGuid().ToString('N')).tmp" [IO.File]::WriteAllText($temp, $Text, (New-Object Text.UTF8Encoding($false))) for ($attempt = 1; $attempt -le 5; $attempt++) { try { if (Test-Path -LiteralPath $Path) { [IO.File]::Replace($temp, $Path, [NullString]::Value) } else { [IO.File]::Move($temp, $Path) } return } catch { if ($attempt -eq 5) { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue; throw } Start-Sleep -Milliseconds (50 * $attempt) } } } function Write-SLUpdateCache { param([Parameter(Mandatory)] $Cache) $dir = Get-SLCacheDirectory Write-SLAtomicText -Path (Join-Path $dir 'update-cache.json') -Text ($Cache | ConvertTo-Json -Depth 6) # The profile block reads only this: the earliest time any entry is due again. $next = [long]::MaxValue foreach ($p in $Cache.Entries.PSObject.Properties) { if ([long] $p.Value.NextCheckTicks -lt $next) { $next = [long] $p.Value.NextCheckTicks } } if ($next -eq [long]::MaxValue) { $next = 0 } Write-SLAtomicText -Path (Join-Path $dir 'next-check.txt') -Text "$next" } # Holds the lookup lock: a file opened exclusively, deleted when closed (also when the # process ends). Returns the open stream, or $null if another process holds it. function Enter-SLUpdateLock { $dir = Get-SLCacheDirectory if (-not (Test-Path -LiteralPath $dir)) { [void] (New-Item -ItemType Directory -Path $dir -Force) } $file = Join-Path $dir 'update-cache.lock' for ($attempt = 1; $attempt -le 2; $attempt++) { try { return (New-Object IO.FileStream($file, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None, 16, [IO.FileOptions]::DeleteOnClose)) } catch { $info = Get-Item -LiteralPath $file -ErrorAction SilentlyContinue $stale = $info -and ([DateTime]::UtcNow - $info.LastWriteTimeUtc).TotalMinutes -gt $script:SLLockStaleMinutes if (-not $stale -or $attempt -eq 2) { return $null } Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } } return $null } function Get-SLCacheKey { param([string] $Name, [bool] $IncludePrerelease) $channel = 'stable' if ($IncludePrerelease) { $channel = 'prerelease' } return "PSGallery/$Name/$channel" } # ----------------------------------------------------------------- lookup -- # The newest version of Name on the PowerShell Gallery (v2 API), as a string. # Bounded: TimeoutSeconds per request, 1 + Retries requests, 1 MB response. function Get-SLGalleryVersion { param( [string] $Name = 'SecondLine', [int] $TimeoutSeconds = 3, [int] $Retries = 1, [switch] $IncludePrerelease ) if ($Name -notmatch '^[A-Za-z0-9._-]+$') { throw "Invalid module name '$Name'." } $filter = 'IsLatestVersion' if ($IncludePrerelease) { $filter = 'IsAbsoluteLatestVersion' } $uri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id='$Name'&`$filter=$filter" $lastError = $null for ($attempt = 0; $attempt -le $Retries; $attempt++) { if ($attempt -gt 0) { Start-Sleep -Milliseconds 500 } try { return (Invoke-SLGalleryRequest -Uri $uri -TimeoutSeconds $TimeoutSeconds -IncludePrerelease:$IncludePrerelease) } catch { $lastError = $_.Exception.Message } } throw "Gallery lookup for $Name failed after $($Retries + 1) attempt(s): $lastError" } function Invoke-SLGalleryRequest { param([string] $Uri, [int] $TimeoutSeconds, [switch] $IncludePrerelease) $client = $null $response = $null try { Add-Type -AssemblyName System.Net.Http -ErrorAction Stop $client = New-Object System.Net.Http.HttpClient $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds) $client.MaxResponseContentBufferSize = 1048576 $response = $client.GetAsync($Uri).GetAwaiter().GetResult() [void] $response.EnsureSuccessStatusCode() $xmlText = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() return (Select-SLGalleryFeedVersion -Xml $xmlText -IncludePrerelease:$IncludePrerelease) } finally { if ($response) { $response.Dispose() } if ($client) { $client.Dispose() } } } # The best version in a Gallery v2 feed: stable only unless -IncludePrerelease. # DTDs and external entities are refused. function Select-SLGalleryFeedVersion { param([Parameter(Mandatory)] [string] $Xml, [switch] $IncludePrerelease) $settings = New-Object System.Xml.XmlReaderSettings $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit $settings.XmlResolver = $null $reader = [System.Xml.XmlReader]::Create((New-Object System.IO.StringReader($Xml)), $settings) try { $document = New-Object System.Xml.XmlDocument $document.XmlResolver = $null $document.Load($reader) } finally { $reader.Dispose() } $versions = @( foreach ($node in $document.SelectNodes("//*[local-name()='properties']")) { $versionNode = $node.SelectSingleNode("*[local-name()='Version']") if (-not $versionNode) { continue } $text = $versionNode.InnerText.Trim() if ($null -eq (ConvertTo-SLVersionParts $text)) { continue } if (-not $IncludePrerelease -and (Test-SLPrerelease $text)) { continue } $text } ) if ($versions.Count -eq 0) { throw 'The Gallery returned no usable version.' } if (-not $IncludePrerelease) { return (Select-SLBestVersion $versions) } # Prerelease channel: simply the newest, even if it is a prerelease. $best = $versions[0] foreach ($v in $versions) { if ((Compare-SLVersion $v $best) -gt 0) { $best = $v } } return $best } # The highest installed version of Name, from the module path. No import. function Get-SLInstalledVersion { param([string] $Name = 'SecondLine') $best = Get-Module -ListAvailable -Name $Name -ErrorAction SilentlyContinue | Sort-Object Version -Descending | Select-Object -First 1 if ($best) { return "$($best.Version)" } return $null } # ------------------------------------------------------------------ status -- # Turns a cache entry into a status, without network access: # Lookup Current | Stale | Failed | NotChecked | Disabled # Update UpdateAvailable | UpToDate | Unknown function Get-SLUpdateState { param( $Entry, [hashtable] $Options, [string] $Installed, [string] $Name = 'SecondLine' ) $now = [DateTime]::UtcNow.Ticks $latest = $null $checked = $null $lookup = 'NotChecked' $errorText = $null if ($Entry) { if ($Entry.Latest) { $latest = "$($Entry.Latest)" } if ($Entry.CheckedTicks) { $checked = New-Object DateTime ([long] $Entry.CheckedTicks), ([DateTimeKind]::Utc) } $failedLater = $Entry.LastError -and [long] $Entry.AttemptTicks -gt [long] $Entry.CheckedTicks if ($failedLater) { $lookup = 'Failed'; $errorText = "$($Entry.LastError)" } elseif ($checked -and ($now - $checked.Ticks) -lt [TimeSpan]::FromHours($Options.RefreshHours).Ticks) { $lookup = 'Current' } elseif ($checked) { $lookup = 'Stale' } } if (-not $Options.Enabled -and $lookup -ne 'Current') { $lookup = 'Disabled' } $update = 'Unknown' if ($latest -and $Installed) { if ((Compare-SLVersion $latest $Installed) -gt 0) { $update = 'UpdateAvailable' } else { $update = 'UpToDate' } } $message = switch ($update) { 'UpdateAvailable' { "$Name $latest available; installed $Installed." } 'UpToDate' { "$Name $Installed is the newest stable release." } default { if ($latest) { "Newest stable $Name is $latest; no installed $Name found to compare." } else { "$Name update status unknown." } } } $nextDue = $null if ($Entry -and $Entry.NextCheckTicks) { $nextDue = New-Object DateTime ([long] $Entry.NextCheckTicks), ([DateTimeKind]::Utc) } return [pscustomobject]@{ Name = $Name Installed = $Installed Latest = $latest Update = $update Lookup = $lookup CheckedUtc = $checked NextDueUtc = $nextDue LastError = $errorText Message = $message } } # Age as '3 h ago', for one-line status. function Format-SLAge { param($Utc) if (-not $Utc) { return 'never' } $span = [DateTime]::UtcNow - [DateTime] $Utc if ($span.TotalMinutes -lt 1) { return 'just now' } if ($span.TotalHours -lt 1) { return "$([int] $span.TotalMinutes) min ago" } if ($span.TotalDays -lt 2) { return "$([int] $span.TotalHours) h ago" } return "$([int] $span.TotalDays) days ago" } # ----------------------------------------------------------------- refresh -- # Refreshes the shared cache entry for Name when it is due (or -Force), under the lock. # Returns the state, plus Ran (a request was made), Busy (another process holds the # lock) and Notice (an actionable line not yet shown, else $null). # -NoticeFile also leave the notice in notice.txt, for the next interactive shell function Update-SLUpdateCache { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private. It writes only the SecondLine cache; Invoke-SLUpdateCheck and Show-SLHelp document that.')] param( [string] $Name = 'SecondLine', [hashtable] $Options, [switch] $Force, [switch] $NoticeFile ) if (-not $Options) { $Options = ConvertTo-SLUpdateOptions (Get-SLUpdateSettings) } $key = Get-SLCacheKey -Name $Name -IncludePrerelease ([bool] $Options.IncludePrerelease) $ran = $false $busy = $false $notice = $null # Without the lock, only read: another process is writing the cache right now. $lock = Enter-SLUpdateLock $busy = $null -eq $lock try { # Read inside the lock: another process may have just finished this lookup. $cache = Read-SLUpdateCache $entry = $null if ($cache.Entries.PSObject.Properties[$key]) { $entry = $cache.Entries.$key } $now = [DateTime]::UtcNow $due = -not $entry -or [long] $entry.NextCheckTicks -le $now.Ticks if ($lock -and ($Force -or ($due -and $Options.Enabled))) { if (-not $entry) { $entry = [pscustomobject]@{ Latest = $null; CheckedTicks = 0; AttemptTicks = 0; LastError = $null; NextCheckTicks = 0 } $cache.Entries | Add-Member -NotePropertyName $key -NotePropertyValue $entry } $ran = $true $entry.AttemptTicks = $now.Ticks try { $entry.Latest = Get-SLGalleryVersion -Name $Name -TimeoutSeconds $Options.TimeoutSeconds -Retries $Options.Retries -IncludePrerelease:$Options.IncludePrerelease $entry.CheckedTicks = [DateTime]::UtcNow.Ticks $entry.LastError = $null $entry.NextCheckTicks = $now.AddHours($Options.RefreshHours).Ticks } catch { $entry.LastError = $_.Exception.Message $entry.NextCheckTicks = $now.AddMinutes($Options.FailureBackoffMinutes).Ticks } } elseif (-not $Options.Enabled -and -not $Force) { # Disabled: push the profile block's next look out, so it doesn't start a process every shell. if (-not $entry) { $entry = [pscustomobject]@{ Latest = $null; CheckedTicks = 0; AttemptTicks = 0; LastError = $null; NextCheckTicks = 0 } $cache.Entries | Add-Member -NotePropertyName $key -NotePropertyValue $entry } $entry.NextCheckTicks = $now.AddHours($Options.RefreshHours).Ticks } $state = Get-SLUpdateState -Entry $entry -Options $Options -Installed (Get-SLInstalledVersion -Name $Name) -Name $Name $noticeKey = "$Name|$($state.Latest)|$($state.Installed)" if ($state.Update -eq 'UpdateAvailable' -and $cache.NoticeShown -ne $noticeKey) { $notice = $state.Message if ($lock) { $cache.NoticeShown = $noticeKey } } if ($lock) { Write-SLUpdateCache -Cache $cache if ($NoticeFile -and $notice) { Write-SLAtomicText -Path (Join-Path (Get-SLCacheDirectory) 'notice.txt') -Text $notice } } } finally { if ($lock) { $lock.Dispose() } } $state | Add-Member -NotePropertyName Ran -NotePropertyValue $ran $state | Add-Member -NotePropertyName Busy -NotePropertyValue $busy $state | Add-Member -NotePropertyName Notice -NotePropertyValue $notice return $state } # The cached state for Name. Reads local files only: never the network. function Get-SLCachedUpdateState { param([string] $Name = 'SecondLine', [hashtable] $Options) if (-not $Options) { $Options = ConvertTo-SLUpdateOptions (Get-SLUpdateSettings) } $cache = Read-SLUpdateCache $key = Get-SLCacheKey -Name $Name -IncludePrerelease ([bool] $Options.IncludePrerelease) $entry = $null if ($cache.Entries.PSObject.Properties[$key]) { $entry = $cache.Entries.$key } return (Get-SLUpdateState -Entry $entry -Options $Options -Installed (Get-SLInstalledVersion -Name $Name) -Name $Name) } # -------------------------------------------------------------- background -- # Starts one background refresh for this session, unless one is running, the cache is # current (without -Force), checks are disabled, or a failed start is backing off. # Returns Started | InFlight | NotNeeded | Disabled | Unavailable. Never waits on the network. # When the job ends, an event action prints the notice (if any) and removes the job # and its subscription. function Start-SLBackgroundUpdateCheck { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private. It starts a session job for the opt-out help check (-NoUpdateCheck); confirming each time would defeat background help.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '', Justification = 'The job script block declares parameters; every value comes from -ArgumentList.')] param([hashtable] $Options, $State, [string] $Directory, [switch] $Force) $session = Get-Variable -Name SLUpdateJobState -Scope Script -ValueOnly -ErrorAction SilentlyContinue if (-not $session) { $session = @{ Job = $null; RetryUtc = [DateTime]::MinValue; Result = $null } $script:SLUpdateJobState = $session } if ($session.Job) { return 'InFlight' } if (-not $Options.Enabled) { return 'Disabled' } if (-not $Force -and $State -and $State.Lookup -eq 'Current') { return 'NotNeeded' } # A failed lookup has a shared backoff; don't start a process just to find that out. if (-not $Force -and $State -and $State.Lookup -eq 'Failed' -and $State.NextDueUtc -and $State.NextDueUtc -gt [DateTime]::UtcNow) { return 'NotNeeded' } if ([DateTime]::UtcNow -lt $session.RetryUtc) { return 'Unavailable' } try { $manifest = Join-Path $ExecutionContext.SessionState.Module.ModuleBase 'SecondLine.psd1' if (-not $Directory) { $Directory = (Get-Location).ProviderPath } $session.Job = Start-Job -ScriptBlock { param($Manifest, $Directory, $Force) $ErrorActionPreference = 'Stop' Set-Location -LiteralPath $Directory Import-Module $Manifest -Force SecondLine\Invoke-SLUpdateCheck -Force:$Force -Quiet -PassThru } -ArgumentList $manifest, $Directory, ([bool] $Force) -ErrorAction Stop $sourceId = 'SecondLine.UpdateCheck.' + [guid]::NewGuid().ToString('N') Register-ObjectEvent -InputObject $session.Job -EventName StateChanged -SourceIdentifier $sourceId -MessageData $session -Action { $shared = $event.MessageData $job = $shared.Job if ($job -and $job.State -in @('Completed', 'Failed', 'Stopped')) { try { $result = @(Receive-Job -Job $job -ErrorAction SilentlyContinue) | Select-Object -Last 1 $shared.Result = $result if ($job.State -eq 'Completed' -and $result -and $result.Notice) { Write-Host $result.Notice -ForegroundColor Yellow } elseif ($job.State -ne 'Completed') { $shared.RetryUtc = [DateTime]::UtcNow.AddMinutes(5) } } finally { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue $shared.Job = $null Unregister-Event -SourceIdentifier $eventSubscriber.SourceIdentifier -ErrorAction SilentlyContinue if ($eventSubscriber.Action) { Remove-Job -Job $eventSubscriber.Action -Force -ErrorAction SilentlyContinue } } } } -ErrorAction Stop | Out-Null # The job may have finished before the subscription existed. if ($session.Job -and $session.Job.State -in @('Completed', 'Failed', 'Stopped')) { New-Event -SourceIdentifier $sourceId -MessageData $session | Out-Null } return 'Started' } catch { if ($session.Job) { Remove-Job -Job $session.Job -Force -ErrorAction SilentlyContinue } $session.Job = $null $session.RetryUtc = [DateTime]::UtcNow.AddMinutes(5) return 'Unavailable' } } # ------------------------------------------------------------- profile block -- $script:SLProfileStart = '# >>> SecondLine update check >>>' $script:SLProfileEnd = '# <<< SecondLine update check <<<' # The profile block's lines. It imports nothing into the shell, so a project's # bootstrap can still choose its approved SecondLine version later in the session. # It reads two small files; when a check is due it starts one hidden, no-profile # PowerShell process that runs the shared check and exits. function Get-SLProfileBlock { return @( $script:SLProfileStart '# Added by Enable-SLProfileUpdateCheck. Remove with Disable-SLProfileUpdateCheck, or delete through the end marker.' '# Shows a pending SecondLine update notice and, at most daily, starts a background check. Installs nothing.' 'if ([Environment]::UserInteractive -and $env:SECONDLINE_NO_UPDATE_CHECK -ne ''1'' -and -not ([Environment]::GetCommandLineArgs() -match ''^-NonI'')) {' ' try {' ' $slDir = $env:SECONDLINE_CACHE_DIR' ' if (-not $slDir) { $slDir = Join-Path ([Environment]::GetFolderPath(''LocalApplicationData'')) ''SecondLine'' }' ' $slNotice = Join-Path $slDir ''notice.txt''' ' if (Test-Path -LiteralPath $slNotice) { Write-Host ([IO.File]::ReadAllText($slNotice).Trim()) -ForegroundColor Yellow; Remove-Item -LiteralPath $slNotice -ErrorAction SilentlyContinue }' ' $slNext = Join-Path $slDir ''next-check.txt''' ' $slDue = 0L' ' if (Test-Path -LiteralPath $slNext) { [void] [long]::TryParse([IO.File]::ReadAllText($slNext).Trim(), [ref] $slDue) }' ' if ($slDue -le [DateTime]::UtcNow.Ticks) {' ' Start-Process -FilePath (Get-Process -Id $PID).Path -WindowStyle Hidden -ArgumentList ''-NoProfile'', ''-NonInteractive'', ''-Command'', ''Import-Module SecondLine -MinimumVersion 0.4.0; SecondLine\Invoke-SLUpdateCheck -NoticeFile -Quiet''' ' }' ' }' ' catch { }' ' Remove-Variable slDir, slNotice, slNext, slDue -ErrorAction SilentlyContinue' '}' $script:SLProfileEnd ) } # Each marked block in Text: Start, BlockLength (markers included) and Length (with # the line ending after it). A start marker without an end marker is an error. function Find-SLProfileBlock { param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Text) $blocks = @() $pattern = '(?m)^' + [regex]::Escape($script:SLProfileStart) + '[^\r\n]*' $index = 0 while ($true) { $m = [regex]::Match($Text.Substring($index), $pattern) if (-not $m.Success) { break } $start = $index + $m.Index $end = $Text.IndexOf($script:SLProfileEnd, $start, [StringComparison]::Ordinal) if ($end -lt 0) { throw "The SecondLine profile block that starts at character $start has no end marker '$($script:SLProfileEnd)'. Nothing was changed; fix the block by hand." } $blockLength = $end + $script:SLProfileEnd.Length - $start $length = $blockLength if ($Text.Length -gt $start + $length -and $Text[$start + $length] -eq "`r") { $length++ } if ($Text.Length -gt $start + $length -and $Text[$start + $length] -eq "`n") { $length++ } $blocks += [pscustomobject]@{ Start = $start; BlockLength = $blockLength; Length = $length } $index = $start + $length } return , $blocks } # ------------------------------------------------------------- reporting -- # One concise line for a state: actionable first, then how fresh the answer is. function Format-SLUpdateLine { param([Parameter(Mandatory)] $State, [string] $DisabledBy) $age = Format-SLAge $State.CheckedUtc $line = switch ($State.Lookup) { 'Current' { "$($State.Message) (checked $age)" } 'Stale' { "$($State.Message) Last Gallery check $age; refresh due." } 'Failed' { $when = '' if ($State.NextDueUtc) { $when = " Next attempt after $($State.NextDueUtc.ToLocalTime().ToString('HH:mm'))." } "Gallery lookup failed (offline?); update status unknown, not up to date. Last success: $age.$when" } 'Disabled' { "Update checks are off ($DisabledBy). Last Gallery check: $age." } default { 'Not checked yet. Run Invoke-SLUpdateCheck, or open Show-SLHelp to check in the background.' } } if ($State.Lookup -eq 'Failed' -and $State.Update -eq 'UpdateAvailable') { $line = "$($State.Message) $line" } if ($State.PSObject.Properties['Busy'] -and $State.Busy) { $line = "Another session is checking right now; cached result: $line" } return $line } # Update availability, version policy and advisories for SecondLine, from local # files only. The three categories stay separate: # Update UpdateAvailable | UpToDate | Unknown (newest Gallery release vs highest installed) # Policy Match | Mismatch | NotInstalled | NotPinned | Invalid (the loaded version vs the project's ModuleVersions) # Advisory NotConfigured: SecondLine has no security-advisory source and reports none function Get-SLDependencyHealth { param([Parameter(Mandatory)] $State, [string] $Standard, [string] $Directory, [string] $Loaded) if (-not $Loaded) { $Loaded = "$($ExecutionContext.SessionState.Module.Version)" } $approved = $null $approvedSource = $null $policy = 'NotPinned' $policyNote = 'No ModuleVersions.SecondLine in the project or settings.' try { $versions = Resolve-SLModuleVersionPolicy -Standard $Standard -Directory $Directory -DevelopmentPath $script:SLDevelopmentStandard if ($versions.ContainsKey('SecondLine')) { $approved = "$($versions['SecondLine'].Value)" $approvedSource = $versions['SecondLine'].Source if ((Compare-SLVersion $approved $Loaded) -eq 0) { $policy = 'Match'; $policyNote = "Loaded $Loaded is approved by $approvedSource." } else { $policy = 'Mismatch' $policyNote = "Version-policy mismatch: loaded $Loaded; $approvedSource approves $approved." $have = @(Get-Module -ListAvailable -Name SecondLine -ErrorAction SilentlyContinue | Where-Object { "$($_.Version)" -eq $approved }) if ($have.Count -eq 0) { $policy = 'NotInstalled'; $policyNote = "SecondLine $approved is approved by $approvedSource but not installed; loaded $Loaded." } } } } catch { $policy = 'Invalid'; $policyNote = "Version policy could not be read: $($_.Exception.Message)" } $notices = @() if ($State.Update -eq 'UpdateAvailable') { $notices += $State.Message } if ($policy -in 'Mismatch', 'NotInstalled', 'Invalid') { $notices += $policyNote } return [pscustomobject]@{ PSTypeName = 'SecondLine.DependencyHealth' Name = 'SecondLine' Loaded = $Loaded Installed = $State.Installed Latest = $State.Latest Update = $State.Update Lookup = $State.Lookup CheckedUtc = $State.CheckedUtc LastError = $State.LastError Approved = $approved ApprovedSource = $approvedSource Policy = $policy PolicyNote = $policyNote Advisory = 'NotConfigured' AdvisoryNote = 'No security-advisory source is configured; SecondLine reports no advisories rather than guess.' Notices = $notices } } |