Files
cmbot/scripts/update.ps1
T
adminandClaude Opus 4.8 275ce852c3 feat: LAN auto-update launcher script (stage 3)
scripts/update.ps1: check manifest, stage+verify the new version, swap it in
alongside the running one (versions\<ver> + current.txt pointer), then launch
with CMBOT_DATA_DIR. Degrades to the local version on any failure; never
overwrites the running version. Rollback = edit current.txt.

Verified against a fake versioned layout: update, idempotent re-run, unreachable
source, no source, corrupt download (marker missing), malformed manifest.

Not yet wired into a real install layout (build.ps1 still ships flat onedir).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:29:56 +08:00

196 lines
6.5 KiB
PowerShell

<#
.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\<ver> and flips current.txt;
4. launches versions\<current>\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\<ver>, 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