<# .SYNOPSIS LAN auto-update launcher for CMBot (docs/10-lan-update.md, stage 3). .DESCRIPTION Run instead of launching CMBot.exe directly. On each start it: 1. reads the local version from current.txt; 2. reads update_source from data\config\app_config.json and the remote manifest.json there; 3. if a newer version is advertised, copies it into staging, verifies it, then atomically swaps it into versions\ and flips current.txt; 4. launches versions\\CMBot.exe with CMBOT_DATA_DIR pointed at the shared data\ folder. Degrades safely: an unreachable source, a malformed manifest, or a failed copy never blocks startup — the existing local version is launched instead. Never overwrites the running version (new version installs alongside). .PARAMETER InstallRoot Install directory holding current.txt, versions\, data\, staging\. Defaults to the launcher's own folder. .PARAMETER NoLaunch Do everything except start the app. For testing the update flow. #> [CmdletBinding()] param( [string]$InstallRoot = $PSScriptRoot, [switch]$NoLaunch ) $ErrorActionPreference = "Stop" $AppExe = "CMBot.exe" $ManifestName = "manifest.json" $CurrentTxt = Join-Path $InstallRoot "current.txt" $VersionsDir = Join-Path $InstallRoot "versions" $DataDir = Join-Path $InstallRoot "data" $StagingDir = Join-Path $InstallRoot "staging" $ConfigFile = Join-Path $DataDir "config\app_config.json" $LogFile = Join-Path $InstallRoot "launcher.log" function Write-Log { param([string]$Message) $line = "{0} {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message Write-Host $line try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { } } function Get-LocalVersion { if (Test-Path -LiteralPath $CurrentTxt) { return (Get-Content -LiteralPath $CurrentTxt -Raw).TrimStart([char]0xFEFF).Trim() } return "" } function Get-UpdateSource { if (-not (Test-Path -LiteralPath $ConfigFile)) { return "" } try { $cfg = Get-Content -LiteralPath $ConfigFile -Raw | ConvertFrom-Json return [string]$cfg.update_source } catch { Write-Log "Cannot read update_source: $_" return "" } } function ConvertTo-VersionParts { param([string]$Text) $parts = @(0, 0, 0) if ($Text) { $chunks = $Text.Trim().Split(".") for ($i = 0; $i -lt 3 -and $i -lt $chunks.Count; $i++) { $digits = ($chunks[$i] -replace '^(\d*).*$', '$1') if ($digits) { $parts[$i] = [int]$digits } } } return ,$parts } function Test-IsNewer { param([string]$Remote, [string]$Local) $r = ConvertTo-VersionParts $Remote $l = ConvertTo-VersionParts $Local for ($i = 0; $i -lt 3; $i++) { if ($r[$i] -gt $l[$i]) { return $true } if ($r[$i] -lt $l[$i]) { return $false } } return $false } function Invoke-Update { param([string]$Source, [string]$LocalVersion) $manifestPath = Join-Path $Source $ManifestName if (-not (Test-Path -LiteralPath $manifestPath)) { Write-Log "No manifest at $manifestPath - skip update." return } try { $m = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json } catch { Write-Log "Manifest unreadable - skip update: $_" return } $remoteVer = [string]$m.version if (-not $remoteVer) { Write-Log "Manifest has no version - skip update." return } if (-not (Test-IsNewer $remoteVer $LocalVersion)) { Write-Log "Up to date (local '$LocalVersion', remote '$remoteVer')." return } $srcFiles = [string]$m.source if (-not $srcFiles) { $srcFiles = $Source } if (-not (Test-Path -LiteralPath $srcFiles)) { Write-Log "Version folder not found: $srcFiles - skip update." return } $marker = if ($m.marker) { [string]$m.marker } else { $AppExe } # 1. copy into a temp staging folder (never into versions\ directly) $stageTmp = Join-Path $StagingDir ("{0}.tmp" -f $remoteVer) if (Test-Path -LiteralPath $stageTmp) { Remove-Item -LiteralPath $stageTmp -Recurse -Force } New-Item -ItemType Directory -Force -Path $stageTmp | Out-Null Write-Log "Downloading v$remoteVer from $srcFiles ..." & robocopy $srcFiles $stageTmp /E /NFL /NDL /NJH /NJS /NP /R:1 /W:1 | Out-Null if ($LASTEXITCODE -ge 8) { Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue throw "robocopy failed (exit $LASTEXITCODE)" } # 2. verify completeness via the marker file if (-not (Test-Path -LiteralPath (Join-Path $stageTmp $marker))) { Remove-Item -LiteralPath $stageTmp -Recurse -Force -ErrorAction SilentlyContinue throw "marker '$marker' missing after copy - corrupt download" } # 3. atomic-ish swap: rename staging -> versions\, then flip pointer $verDir = Join-Path $VersionsDir $remoteVer if (Test-Path -LiteralPath $verDir) { Remove-Item -LiteralPath $verDir -Recurse -Force } Move-Item -LiteralPath $stageTmp -Destination $verDir $ptrTmp = "$CurrentTxt.tmp" # ASCII keeps the pointer file BOM-free (version strings are ASCII). Set-Content -LiteralPath $ptrTmp -Value $remoteVer -Encoding ascii -NoNewline Move-Item -LiteralPath $ptrTmp -Destination $CurrentTxt -Force Write-Log "Installed and switched to v$remoteVer." } # ── main ────────────────────────────────────────────────────────────────────── New-Item -ItemType Directory -Force -Path $VersionsDir | Out-Null New-Item -ItemType Directory -Force -Path $StagingDir | Out-Null $local = Get-LocalVersion $source = Get-UpdateSource if ($source) { try { Invoke-Update -Source $source -LocalVersion $local } catch { Write-Log "Update skipped (degraded to local version): $_" } } else { Write-Log "No update_source configured - skip update check." } $current = Get-LocalVersion if (-not $current) { Write-Log "ERROR: no installed version to launch (current.txt missing)." exit 1 } $exe = Join-Path (Join-Path $VersionsDir $current) $AppExe if (-not (Test-Path -LiteralPath $exe)) { Write-Log "ERROR: executable not found: $exe" exit 1 } if ($NoLaunch) { Write-Log "NoLaunch: would start $exe (CMBOT_DATA_DIR=$DataDir)" exit 0 } Write-Log "Launching v$current ..." $env:CMBOT_DATA_DIR = $DataDir Start-Process -FilePath $exe