<# Copyright (c) 2026, R. W. Rodolico All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #> <# .SYNOPSIS Updates the installed Windows sysinfo client from a release zip. .DESCRIPTION Windows counterpart of the Unix sysinfo_update (Documentation/Spec.md sections 4.5 and 7.7). Non-interactive; run elevated, by hand or from a scheduled task. Reads the config key update (url, sha256_url): there are no built-in URLs, so with none the updater stops and names the key to set. Reads the checksum file sha256_url first and compares it with the checksum recorded in the config directory (update_installed.sha256) when the last release was installed; equal means nothing to do and the zip is not downloaded. Otherwise (including no record) downloads the release zip, verifies it against that checksum (a missing checksum file is a warning and the version numbers are compared instead; -RequireChecksum makes it fatal), unpacks it to a locked temporary directory under the config directory, reads the version the new release declares WITHOUT running it, and when it differs runs the new release's install.ps1 -Quiet, then records the checksum. An existing config, server.info and scheduled time are kept, as for any upgrade. Ends with 'RESULT: ok|nochange|fail '. Exit 0 ok, 1 nochange (already current; -Force reinstalls), 2 fail. Must stay Windows PowerShell 5.1 compatible and pure ASCII. Dot-sourcing the script (the tests do) defines its functions without running it. Version 1.2.0 20261027 Claude Mimics the Unix updater: reads the published .sha256 first and compares it with update_installed.sha256 in the config directory (none recorded = not matched); equal means nochange without downloading the zip, otherwise it installs whatever the version says and records the new checksum. Version numbers are compared only when no checksum is published. Adds -Pause (hold the window open at the end, for a desktop shortcut) and, with -Pause, re-launches itself elevated (UAC prompt) when started from a non-elevated shortcut. A failure to load the client is now reported (and paused on) instead of closing the window. Version 1.1.0 20261025 Claude Initial version. Moves out of the program directory before running the installer (a shell sitting in it made the delete fail as 'in use'). .PARAMETER Url Release zip (http(s) URL or a local path); overrides config update.url. .PARAMETER Sha256Url Checksum file for the zip (http(s) URL or path; first 64 hex digits are used); overrides config update.sha256_url. .PARAMETER Config Config file to read instead of searching (for tests). .PARAMETER Check Report whether a different version is available; install nothing. .PARAMETER Force Install even when the release has the same (or an older) version. .PARAMETER RequireChecksum Fail when no checksum file is available. .PARAMETER Quiet Print only the RESULT line (the log file still gets everything). .PARAMETER Pause For a desktop shortcut: when not elevated, re-launch elevated (UAC prompt) in a new window, and wait for Enter before the window closes so the result can be read. Not for scheduled tasks. Shortcut target: powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\camp\sysinfo_client\sysinfo_update.ps1" -Pause #> [CmdletBinding()] param( [string]$Url = '', [string]$Sha256Url = '', [string]$Config = '', [switch]$Check, [switch]$Force, [switch]$RequireChecksum, [switch]$Quiet, [switch]$Pause ) $ErrorActionPreference = 'Stop' # the client's own functions (config, JSON, elevation); dot-sourcing does not run it. # NOTE: the client's parameters ($Test, $Version, ConfigFile ...) become variables here, # which is why this script's own parameters use other names. $script:UpdaterVersion = '1.2.0' $script:UpdateHome = $PSScriptRoot $script:UpdatePause = [bool]$Pause try { . ([IO.Path]::Combine($script:UpdateHome, 'sysinfo_client.ps1')) } catch { [Console]::Error.WriteLine("RESULT: fail unknown cannot load sysinfo_client.ps1 from $($script:UpdateHome): $($_.Exception.Message)") if ($script:UpdatePause) { try { [void](Read-Host 'Press Enter to close') } catch { } } exit 2 } $ErrorActionPreference = 'Stop' $script:UpdateLogPath = '' $script:UpdateQuiet = [bool]$Quiet $script:ResultVersion = $script:CLIENT_VERSION $script:WorkDir = '' ####################################################### # # Write-UpdateLog( $Message ) # # Appends a line to sysinfo_update.log in the config # directory (truncated over 1 MB) and echoes it unless # -Quiet. # ####################################################### function Write-UpdateLog { param([string]$Message) $line = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $Message if (-not $script:UpdateQuiet) { [Console]::Out.WriteLine($line) } if ($script:UpdateLogPath -ne '') { try { [IO.File]::AppendAllText($script:UpdateLogPath, $line + "`r`n", $script:Utf8NoBom) } catch { # logging must never stop the update } } } ####################################################### # # Write-UpdateResult( $State, $Message ) # # Prints the RESULT line and exits: ok 0, nochange 1, fail 2. # ####################################################### function Write-UpdateResult { param([string]$State, [string]$Message) Write-UpdateLog "RESULT: $State $($script:ResultVersion) $Message" if ($script:UpdateQuiet) { [Console]::Out.WriteLine("RESULT: $State $($script:ResultVersion) $Message") } if ($script:UpdatePause) { try { [void](Read-Host 'Press Enter to close') } catch { } } # exit does not reliably run a caller's finally block, so the work directory goes here if ($script:WorkDir -ne '') { Remove-Item -LiteralPath $script:WorkDir -Recurse -Force -ErrorAction SilentlyContinue } if ($State -eq 'ok') { exit 0 } if ($State -eq 'nochange') { exit 1 } exit 2 } ####################################################### # # Get-UpdateBytes( $Source ) # # Returns the file or URL as bytes. Throws; a missing # resource has the message 'NOTFOUND' so a missing # checksum can be told from a network failure. # ####################################################### function Get-UpdateBytes { param([string]$Source) if ($Source -match '^https?://') { try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { # not settable on this runtime } $web = New-Object System.Net.WebClient try { return , $web.DownloadData($Source) } catch [System.Net.WebException] { $response = $_.Exception.Response if ($null -ne $response -and [int]$response.StatusCode -eq 404) { throw 'NOTFOUND' } throw } finally { $web.Dispose() } } if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { throw 'NOTFOUND' } return , [IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Source).ProviderPath) } ####################################################### # # Get-UpdateSettings( $ConfigMap ) # # url and sha256_url: the parameters, else config update. # ####################################################### function Get-UpdateSettings { param($ConfigMap, [string]$UrlOverride, [string]$ShaOverride) $update = Get-SysinfoValue $ConfigMap 'update' $url = $UrlOverride if ($url -eq '') { $url = [string](Get-SysinfoValue $update 'url' '') } $sha = $ShaOverride if ($sha -eq '') { $sha = [string](Get-SysinfoValue $update 'sha256_url' '') } return @{ 'url' = $url; 'sha256_url' = $sha } } ####################################################### # # Read-ChecksumText( $Text ) # # First 64 hex digits in the text (sha256sum or bare # format), lower case; '' when there are none. # ####################################################### function Read-ChecksumText { param([string]$Text) $match = [regex]::Match($Text, '(?i)(?&1 | Out-String $code = $LASTEXITCODE } finally { $ErrorActionPreference = $saved } $resultLine = '' foreach ($line in ($output -split "`r?`n")) { if ($line -like 'RESULT:*') { $resultLine = $line.Trim() } } if ($resultLine -eq '') { $resultLine = $output.Trim() } return @{ 'code' = $code; 'result' = $resultLine } } ####################################################### # # Invoke-Update # # Main program; never returns (Write-UpdateResult exits). # ####################################################### function Invoke-Update { if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { Write-UpdateResult 'fail' 'Windows only' } if ($PSVersionTable.PSVersion -lt [version]'5.1') { Write-UpdateResult 'fail' "PowerShell 5.1 or later required (this is $($PSVersionTable.PSVersion))" } if (-not $Check -and -not (Test-SysinfoElevated)) { if ($Pause) { # started from a desktop shortcut: ask for elevation, the new window does the work and pauses $argList = '-NoProfile -ExecutionPolicy Bypass -File "' + $PSCommandPath + '" -Pause' if ($Url -ne '') { $argList += ' -Url "' + $Url + '"' } if ($Sha256Url -ne '') { $argList += ' -Sha256Url "' + $Sha256Url + '"' } if ($Config -ne '') { $argList += ' -Config "' + $Config + '"' } if ($Force) { $argList += ' -Force' } if ($RequireChecksum) { $argList += ' -RequireChecksum' } try { Start-Process -FilePath ([IO.Path]::Combine($env:SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')) -ArgumentList $argList -Verb RunAs $script:UpdatePause = $false Write-UpdateResult 'ok' 'elevated copy started in a new window' } catch { Write-UpdateResult 'fail' "could not start an elevated copy: $($_.Exception.Message)" } } Write-UpdateResult 'fail' 'must run elevated (Run as administrator)' } $configPath = Find-SysinfoConfig $Config if ($configPath -eq '') { Write-UpdateResult 'fail' 'no configuration file found' } try { $configMap = Read-SysinfoConfig $configPath } catch { Write-UpdateResult 'fail' "$configPath is unreadable: $($_.Exception.Message)" } if ($script:ConfDir -and (Test-Path -LiteralPath $script:ConfDir -PathType Container)) { $script:UpdateLogPath = [IO.Path]::Combine($script:ConfDir, 'sysinfo_update.log') try { $info = New-Object System.IO.FileInfo $script:UpdateLogPath if ($info.Exists -and $info.Length -gt 1MB) { Write-SysinfoFile $script:UpdateLogPath '' } } catch { $script:UpdateLogPath = '' } } $settings = Get-UpdateSettings $configMap $Url $Sha256Url if ($settings['url'] -eq '') { Write-UpdateResult 'fail' "no release URL: set update.url in $configPath (or use -Url)" } Write-UpdateLog "sysinfo_update $($script:UpdaterVersion): installed client $($script:CLIENT_VERSION), release $($settings['url'])" $recordPath = '' if ($script:ConfDir -and (Test-Path -LiteralPath $script:ConfDir -PathType Container)) { $recordPath = [IO.Path]::Combine($script:ConfDir, 'update_installed.sha256') } # the published checksum is tiny: read it first, so an unchanged release costs no zip download $expected = '' if ($settings['sha256_url'] -ne '') { try { $expected = Read-ChecksumText ([Text.Encoding]::ASCII.GetString((Get-UpdateBytes $settings['sha256_url']))) } catch { if ($_.Exception.Message -ne 'NOTFOUND') { Write-UpdateResult 'fail' "cannot fetch $($settings['sha256_url']): $($_.Exception.Message)" } } if ($expected -eq '') { if ($RequireChecksum) { Write-UpdateResult 'fail' 'no usable checksum file and -RequireChecksum was given' } Write-UpdateLog 'Warning: no checksum file available, release not verified' } } elseif ($RequireChecksum) { Write-UpdateResult 'fail' "-RequireChecksum given but no sha256_url (set update.sha256_url in $configPath)" } else { Write-UpdateLog 'Warning: no update.sha256_url, release not verified' } # with a published checksum the version number is not consulted: equal to the recorded one means # nothing changed, anything else (including no record) means install if ($expected -ne '') { $recorded = Read-InstalledChecksum $recordPath $installedPresent = Test-Path -LiteralPath ([IO.Path]::Combine($script:BinDir, 'sysinfo_client.ps1')) -PathType Leaf if (-not $Force -and $installedPresent -and $recorded -eq $expected) { Write-UpdateResult 'nochange' 'installed release matches the published checksum (zip not downloaded; -Force reinstalls)' } if ($Check) { $why = 'it differs from the recorded one' if ($recorded -eq '') { $why = 'none is recorded' } Write-UpdateResult 'ok' "update available: the published checksum differs from the installed release ($why); check only, nothing installed" } } $tempRoot = [IO.Path]::GetTempPath() if ($script:ConfDir -and (Test-Path -LiteralPath $script:ConfDir -PathType Container)) { $tempRoot = [IO.Path]::Combine($script:ConfDir, 'update_tmp') } $work = [IO.Path]::Combine($tempRoot, 'u_' + [Guid]::NewGuid().ToString('N')) $script:WorkDir = $work try { [void](New-Item -ItemType Directory -Path $work -Force) $zipPath = [IO.Path]::Combine($work, 'release.zip') try { [IO.File]::WriteAllBytes($zipPath, (Get-UpdateBytes $settings['url'])) } catch { $reason = $_.Exception.Message if ($reason -eq 'NOTFOUND') { $reason = 'not found' } Write-UpdateResult 'fail' "cannot fetch $($settings['url']): $reason" } if ($expected -ne '') { $actual = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne $expected) { Write-UpdateResult 'fail' "checksum mismatch (expected $expected, got $actual)" } Write-UpdateLog 'Checksum verified' } try { $releaseDir = Expand-ReleaseZip $zipPath ([IO.Path]::Combine($work, 'release')) $offered = Get-ReleaseVersion ([IO.Path]::Combine($releaseDir, 'sysinfo_client.ps1')) } catch { Write-UpdateResult 'fail' "bad release zip: $($_.Exception.Message)" } if ($offered -eq '') { Write-UpdateResult 'fail' 'cannot read the release version from its sysinfo_client.ps1' } # no published checksum to go by: fall back to comparing version numbers if ($expected -eq '') { $order = Compare-ReleaseVersion $script:CLIENT_VERSION $offered if ($order -eq 0 -and -not $Force) { Write-UpdateResult 'nochange' "already at $offered (-Force reinstalls)" } if ($order -lt 0 -and -not $Force) { Write-UpdateResult 'nochange' "release $offered is older than installed $($script:CLIENT_VERSION) (-Force installs it)" } if ($Check) { $script:ResultVersion = $offered Write-UpdateResult 'ok' "update available: $($script:CLIENT_VERSION) -> $offered (check only, nothing installed)" } } Write-UpdateLog "Installing $offered over $($script:CLIENT_VERSION)" # the installer deletes the program directory; this process (and the child that inherits its # directory) must not be sitting in it, or Windows refuses the delete as 'in use' Set-Location -LiteralPath $work [Environment]::CurrentDirectory = $work $install = Invoke-ReleaseInstaller $releaseDir Set-Location -LiteralPath $tempRoot [Environment]::CurrentDirectory = $tempRoot if ($install['code'] -ne 0) { Write-UpdateResult 'fail' "installer failed: $($install['result'])" } # the installer clears any old record; this one says which release is now installed Write-InstalledChecksum $recordPath $expected $script:ResultVersion = $offered Write-UpdateResult 'ok' "updated $($script:CLIENT_VERSION) -> $offered" } finally { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } } if ($MyInvocation.InvocationName -ne '.') { try { Invoke-Update } catch { Write-UpdateResult 'fail' $_.Exception.Message } }