<#
.SYNOPSIS
MEC Agent 自動更新腳本。
.DESCRIPTION
提供三種模式:
-Check 比對本地 version.json 與遠端 manifest,回傳 JSON 差異清單
-Stage 下載有更新的元件到 _staged_update/ 暫存區
-ApplyStaged 將暫存區檔案覆蓋至安裝目錄(搭配 -Restart 可自動重啟)
.PARAMETER InstallRoot
安裝目錄(預設為此腳本所在資料夾)。
.PARAMETER ManifestUrl
遠端更新 manifest URL(可由 config.json 的 update.manifestUrl 取得)。
.PARAMETER Check
檢查是否有可用更新,輸出 JSON。
.PARAMETER Stage
下載可用更新到暫存區。
.PARAMETER ApplyStaged
套用暫存區更新到安裝目錄。
.PARAMETER Restart
ApplyStaged 後自動重啟 MEC Agent。
.EXAMPLE
.\mec_updater.ps1 -Check -ManifestUrl "http://update-server/manifest.json"
.\mec_updater.ps1 -Stage -ManifestUrl "http://update-server/manifest.json"
.\mec_updater.ps1 -ApplyStaged -Restart
#>
param(
[string]$InstallRoot = "", # empty = auto-detect from script location
[string]$ManifestUrl = "",
[switch]$Check,
[switch]$Stage,
[switch]$ApplyStaged,
[switch]$Restart
)
# Auto-detect install root: when running from update\ subdir, go up one level
if (-not $InstallRoot) {
$InstallRoot = $PSScriptRoot
if ((Split-Path -Leaf $InstallRoot) -eq 'update') {
$InstallRoot = Split-Path -Parent $InstallRoot
}
}
$ErrorActionPreference = "Stop"
# ── Paths ─────────────────────────────────────────────────────────────────────
$UpdateDir = Join-Path $InstallRoot "update"
$LocalVersionFile = Join-Path $UpdateDir "version.json"
$StagedDir = Join-Path $UpdateDir "_staged_update"
$StagedManifest = Join-Path $StagedDir "manifest.json"
$PendingFlag = Join-Path $UpdateDir "_pending_update"
$ProgressFile = Join-Path $UpdateDir "_progress.json"
$LogFile = Join-Path $InstallRoot "logs\updater.log"
# ── Logging ───────────────────────────────────────────────────────────────────
function Write-Log([string]$msg) {
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$ts] $msg"
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
Add-Content -Path $LogFile -Value $line -Encoding UTF8
Write-Host $line
}
# ── Proxy helper (reuse proxy\pega_proxy.cmd if present) ─────────────────────
function Get-ProxyArgs {
$proxyCmd = Join-Path $InstallRoot "proxy\pega_proxy.cmd"
$proxyUrl = $env:HTTP_PROXY
if (-not $proxyUrl -and (Test-Path $proxyCmd)) {
$lines = Get-Content $proxyCmd -ErrorAction SilentlyContinue
foreach ($line in $lines) {
if ($line -match '(?i)set\s+"?HTTP_PROXY=([^"]+)"?') {
$proxyUrl = $matches[1].Trim()
break
}
}
}
if (-not $proxyUrl) { return @{} }
$args = @{}
if ($proxyUrl -match '^https?://([^:@/]+):([^@/]+)@(.+)$') {
$user = [uri]::UnescapeDataString($Matches[1])
$pass = [uri]::UnescapeDataString($Matches[2])
$hostPart = $Matches[3]
$scheme = if ($proxyUrl -match '^https') { 'https' } else { 'http' }
$args['Proxy'] = "${scheme}://$hostPart"
$securePwd = ConvertTo-SecureString $pass -AsPlainText -Force
$args['ProxyCredential'] = New-Object PSCredential($user, $securePwd)
} else {
$args['Proxy'] = $proxyUrl
}
return $args
}
# ── CA bundle for TLS (reuse proxy\PEGA-CA-02.pem) ───────────────────────────
$caBundlePath = Join-Path $InstallRoot "proxy\PEGA-CA-02.pem"
if (Test-Path $caBundlePath) {
$env:SSL_CERT_FILE = $caBundlePath
$env:REQUESTS_CA_BUNDLE = $caBundlePath
}
# ── Helpers for BOM-free UTF-8 file I/O (PS 5.1 Set-Content adds BOM) ─────────
function Read-Utf8File([string]$path) {
return [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
}
function Write-Utf8File([string]$path, [string]$content) {
$dir = Split-Path -Parent $path
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
[System.IO.File]::WriteAllText($path, $content, (New-Object System.Text.UTF8Encoding($false)))
}
# ── Progress reporter (writes JSON for UI polling) ────────────────────────────
function Write-Progress-File([string]$phase, [int]$current, [int]$total, [string]$component) {
$prog = @{
phase = $phase
current = $current
total = $total
component = $component
timestamp = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
} | ConvertTo-Json -Compress
try { Write-Utf8File $ProgressFile $prog } catch { }
}
function Remove-Progress-File {
Remove-Item $ProgressFile -Force -ErrorAction SilentlyContinue
}
# ── Read local version.json ──────────────────────────────────────────────────
function Get-LocalVersion {
if (-not (Test-Path $LocalVersionFile)) {
Write-Log "WARNING: local version.json not found at $LocalVersionFile"
return @{ version = "0.0.0"; components = @{} }
}
return (Read-Utf8File $LocalVersionFile) | ConvertFrom-Json
}
# ── Fetch remote manifest ────────────────────────────────────────────────────
function Get-RemoteManifest([string]$url) {
if (-not $url) {
throw "ManifestUrl is required. Set it in config.json -> update.manifestUrl or pass -ManifestUrl."
}
Write-Log "Fetching remote manifest from $url"
# Use HttpWebRequest instead of Invoke-WebRequest to avoid a PS 5.1 bug where
# Invoke-WebRequest throws IndexOutOfRangeException for responses from certain
# HTTP servers (e.g. Python http.server) that omit expected headers.
try {
$req = [System.Net.WebRequest]::Create($url)
$req.Method = 'GET'
$req.Timeout = 30000 # 30 s
$proxyArgs = Get-ProxyArgs
if ($proxyArgs.ContainsKey('Proxy')) {
$wp = New-Object System.Net.WebProxy($proxyArgs['Proxy'])
if ($proxyArgs.ContainsKey('ProxyCredential')) {
$net = $proxyArgs['ProxyCredential'].GetNetworkCredential()
$wp.Credentials = New-Object System.Net.NetworkCredential($net.UserName, $net.Password)
}
$req.Proxy = $wp
}
$resp = $req.GetResponse()
$stream = $resp.GetResponseStream()
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8)
$text = $reader.ReadToEnd()
$reader.Close()
$resp.Close()
} catch {
throw "Failed to fetch manifest from ${url}: $($_.Exception.Message)"
}
# Strip BOM: Unicode U+FEFF or UTF-8 BOM bytes misread as Latin-1 ()
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$bom = [char]0xEF + [string][char]0xBB + [char]0xBF
if ($text.Length -ge 3 -and $text.StartsWith($bom)) { $text = $text.Substring(3) }
return $text | ConvertFrom-Json
}
# ── Compare versions ─────────────────────────────────────────────────────────
function Compare-Versions([string]$local, [string]$remote) {
# Handle build-number style (b9049) and semver (0.3.1)
if ($remote -match '^b(\d+)$' -and $local -match '^b(\d+)$') {
return [int]$Matches[1] -gt [int]([regex]::Match($local, 'b(\d+)').Groups[1].Value)
}
try {
return [version]$remote -gt [version]$local
} catch {
return $remote -ne $local
}
}
# ── Verify SHA256 ─────────────────────────────────────────────────────────────
function Test-Sha256([string]$filePath, [string]$expectedHash) {
if (-not $expectedHash -or $expectedHash -match '\.\.\.') {
# No real hash provided (placeholder)
return $true
}
$actual = (Get-FileHash -Path $filePath -Algorithm SHA256).Hash.ToLower()
return $actual -eq $expectedHash.ToLower()
}
# ══════════════════════════════════════════════════════════════════════════════
# MODE: -Check
# ══════════════════════════════════════════════════════════════════════════════
if ($Check) {
if (-not $ManifestUrl) {
# Try reading from update/update_config.json
$cfgPath = Join-Path $InstallRoot "update\update_config.json"
if (Test-Path $cfgPath) {
$cfgRaw = Read-Utf8File $cfgPath
if ($cfgRaw -match '"manifestUrl"\s*:\s*"([^"]+)"') {
$ManifestUrl = $matches[1]
}
}
}
$local = Get-LocalVersion
$remote = Get-RemoteManifest $ManifestUrl
$updates = @()
$remoteComps = $remote.components.PSObject.Properties
foreach ($prop in $remoteComps) {
$name = $prop.Name
$remoteComp = $prop.Value
$localVer = "0.0.0"
if ($local.components.PSObject.Properties[$name]) {
$localVer = $local.components.$name.version
}
if (Compare-Versions $localVer $remoteComp.version) {
$updates += @{
component = $name
localVersion = $localVer
remoteVersion = $remoteComp.version
size = $remoteComp.size
url = $remoteComp.url
}
}
}
$result = @{
hasUpdate = ($updates.Count -gt 0)
currentVersion = $local.version
remoteVersion = $remote.version
releaseNotes = $remote.releaseNotes
updates = $updates
}
$json = $result | ConvertTo-Json -Depth 5 -Compress
Write-Output $json
exit 0
}
# ══════════════════════════════════════════════════════════════════════════════
# MODE: -Stage
# ══════════════════════════════════════════════════════════════════════════════
if ($Stage) {
if (-not $ManifestUrl) {
$cfgPath = Join-Path $InstallRoot "update\update_config.json"
if (Test-Path $cfgPath) {
$cfgRaw = Read-Utf8File $cfgPath
if ($cfgRaw -match '"manifestUrl"\s*:\s*"([^"]+)"') {
$ManifestUrl = $matches[1]
}
}
}
$local = Get-LocalVersion
$remote = Get-RemoteManifest $ManifestUrl
# Clean previous staging
if (Test-Path $StagedDir) {
Remove-Item $StagedDir -Recurse -Force
}
New-Item -ItemType Directory -Path $StagedDir -Force | Out-Null
$downloadedCount = 0
$failedCount = 0
$failedComponents = [System.Collections.Generic.List[string]]::new()
$remoteComps = $remote.components.PSObject.Properties
$totalToDownload = @($remoteComps | Where-Object {
$lv = "0.0.0"
if ($local.components.PSObject.Properties[$_.Name]) { $lv = $local.components.($_.Name).version }
Compare-Versions $lv $_.Value.version
}).Count
$currentIndex = 0
foreach ($prop in $remoteComps) {
$name = $prop.Name
$remoteComp = $prop.Value
$localVer = "0.0.0"
if ($local.components.PSObject.Properties[$name]) {
$localVer = $local.components.$name.version
}
if (-not (Compare-Versions $localVer $remoteComp.version)) {
continue
}
$downloadUrl = $remoteComp.url
if (-not $downloadUrl) {
Write-Log "WARNING: no download URL for component '$name' - skipping"
continue
}
$zipFile = Join-Path $StagedDir "$name.zip"
Write-Log "Downloading $name v$($remoteComp.version) from $downloadUrl ..."
$currentIndex++
Write-Progress-File "downloading" $currentIndex $totalToDownload $name
try {
# Calculate timeout based on declared file size: min 300s, +300s per GB, max 7200s (2h)
$declaredBytes = if ($remoteComp.size) { [long]$remoteComp.size } else { 0 }
$downloadTimeout = [math]::Max(300, [math]::Min(7200, 300 + [math]::Ceiling($declaredBytes / 1GB) * 300))
Write-Log "Download timeout for ${name}: ${downloadTimeout}s (declared $([math]::Round($declaredBytes/1MB,1)) MB)"
$iwArgs = @{
Uri = $downloadUrl
OutFile = $zipFile
UseBasicParsing = $true
TimeoutSec = $downloadTimeout
ErrorAction = 'Stop'
}
$proxy = Get-ProxyArgs
foreach ($k in $proxy.Keys) { $iwArgs[$k] = $proxy[$k] }
Invoke-WebRequest @iwArgs
# Verify checksum
$expectedHash = $remoteComp.sha256
if (-not (Test-Sha256 $zipFile $expectedHash)) {
Write-Log "ERROR: SHA256 mismatch for $name - expected $expectedHash"
Remove-Item $zipFile -Force -ErrorAction SilentlyContinue
$failedComponents.Add($name)
$failedCount++
continue
}
$downloadedCount++
Write-Log "Downloaded $name successfully ($([math]::Round((Get-Item $zipFile).Length / 1MB, 1)) MB)"
} catch {
Write-Log "ERROR: Failed to download $name - $($_.Exception.Message)"
$failedComponents.Add($name)
$failedCount++
}
}
# Save the remote manifest for ApplyStaged to use
$remote | ConvertTo-Json -Depth 5 | ForEach-Object { Write-Utf8File $StagedManifest $_ }
# Create pending flag
if ($downloadedCount -gt 0) {
Write-Utf8File $PendingFlag "staged"
Write-Log "Staging complete: $downloadedCount component(s) downloaded, $failedCount failed."
Write-Progress-File "staged" $downloadedCount $totalToDownload ""
} else {
Write-Log "No components were staged (all up-to-date or download failures)."
Remove-Progress-File
}
$result = @{
staged = $downloadedCount
failed = $failedCount
failedComponents = @($failedComponents)
} | ConvertTo-Json -Compress
Write-Output $result
exit 0
}
# ══════════════════════════════════════════════════════════════════════════════
# MODE: -ApplyStaged
# ══════════════════════════════════════════════════════════════════════════════
if ($ApplyStaged) {
if (-not (Test-Path $PendingFlag)) {
Write-Log "No pending update found."
Write-Output '{"applied":false,"reason":"no_pending_update"}'
exit 0
}
if (-not (Test-Path $StagedManifest)) {
Write-Log "ERROR: staged manifest not found at $StagedManifest"
Write-Output '{"applied":false,"reason":"missing_manifest"}'
exit 1
}
$remote = (Read-Utf8File $StagedManifest) | ConvertFrom-Json
$local = Get-LocalVersion
# Component → target directory/file mapping
$componentTargets = @{
"mec_agent" = @{ type = "file"; target = "mec_agent.exe" }
"api_server" = @{ type = "dir"; target = "api_server"; preserveSubdirs = @("whisper", "skills_pools") }
"whisper" = @{ type = "dir"; target = "api_server\whisper" } # faster-whisper models
"mecui" = @{ type = "dir"; target = "web\mecui" }
"skills" = @{ type = "dir"; target = "skills" }
"python_embed" = @{ type = "dir"; target = "python" }
"llama_cpp" = @{ type = "dir"; target = "llama" }
"whl" = @{ type = "dir"; target = "whl" }
"model" = @{ type = "dir"; target = "models" }
"launcher" = @{ type = "dir"; target = "launcher" } # launcher\ subdir
"config" = @{ type = "dir"; target = "config" } # config\ subdir
"proxy" = @{ type = "dir"; target = "proxy" }
"downloads" = @{ type = "dir"; target = "downloads" } # raw Python embed ZIP; recovery fallback for Mode A-recovery in launcher
"ein_wiki" = @{ type = "dir"; target = "bundled\ein-wiki" }
"workspace_defaults" = @{ type = "dir"; target = "workspace-defaults" } # initial workspace templates
"updater" = @{ type = "file"; target = "update\mec_updater.ps1" } # single file; avoid backing up the update\ dir (contains _backup_*)
}
# Backup directory
$backupDir = Join-Path $UpdateDir "_backup_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
New-Item -ItemType Directory -Path $backupDir -Force | Out-Null
Write-Log "Backup directory: $backupDir"
$appliedCount = 0
$errorCount = 0
$applyComponents = @($remote.components.PSObject.Properties | Where-Object {
Test-Path (Join-Path $StagedDir "$($_.Name).zip")
})
$totalToApply = $applyComponents.Count
$applyIndex = 0
foreach ($prop in $remote.components.PSObject.Properties) {
$name = $prop.Name
$remoteComp = $prop.Value
$zipFile = Join-Path $StagedDir "$name.zip"
if (-not (Test-Path $zipFile)) {
continue # This component was not staged (already up-to-date)
}
$applyIndex++
Write-Progress-File "applying" $applyIndex $totalToApply $name
$mapping = $componentTargets[$name]
if (-not $mapping) {
Write-Log "WARNING: unknown component '$name' - skipping"
continue
}
$targetPath = Join-Path $InstallRoot $mapping.target
try {
# Backup existing
if ($mapping.type -eq "dir" -and (Test-Path $targetPath) -and $mapping.target -ne ".") {
$backupTarget = Join-Path $backupDir $name
# When the target IS the update dir, the backup dir lives inside it.
# Use filtered copy to prevent recursing into _backup_* subdirectories.
if ($targetPath -eq $UpdateDir) {
New-Item -ItemType Directory -Path $backupTarget -Force | Out-Null
Get-ChildItem -Path $targetPath -File | ForEach-Object {
Copy-Item -Path $_.FullName -Destination $backupTarget -Force
}
Get-ChildItem -Path $targetPath -Directory |
Where-Object { $_.Name -notlike '_backup_*' -and $_.Name -ne '_staged_update' } |
ForEach-Object {
Copy-Item -Path $_.FullName -Destination (Join-Path $backupTarget $_.Name) -Recurse -Force
}
} else {
Copy-Item -Path $targetPath -Destination $backupTarget -Recurse -Force
}
Write-Log "Backed up $targetPath -> $backupTarget"
} elseif ($mapping.type -eq "file" -and (Test-Path $targetPath)) {
$backupTarget = Join-Path $backupDir (Split-Path -Leaf $targetPath)
Copy-Item -Path $targetPath -Destination $backupTarget -Force
Write-Log "Backed up $targetPath -> $backupTarget"
}
# Extract update
$extractDir = Join-Path $StagedDir "_extract_$name"
if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force }
Expand-Archive -Path $zipFile -DestinationPath $extractDir -Force
if ($mapping.type -eq "file") {
# Single file: find the file in extract and copy
$srcFile = Get-ChildItem -Path $extractDir -Filter (Split-Path -Leaf $targetPath) -Recurse -File | Select-Object -First 1
if ($srcFile) {
Copy-Item -Path $srcFile.FullName -Destination $targetPath -Force
Write-Log "Updated file: $targetPath"
} else {
Write-Log "WARNING: expected file not found in $name archive"
}
} else {
# Issue #11158-2: Clean-replace — remove existing directory first, then
# copy new content. This avoids leftover files from the old version.
# If the component has preserveSubdirs, save those subdirectories
# before deleting the parent and restore them afterwards. This
# protects child-component directories (e.g. api_server\whisper,
# api_server\skills_pools) that are managed by their own targets.
if ($mapping.target -eq ".") {
# Root-level files: copy each file from extract to install root
Get-ChildItem -Path $extractDir -File | ForEach-Object {
Copy-Item -Path $_.FullName -Destination (Join-Path $InstallRoot $_.Name) -Force
Write-Log "Updated root file: $($_.Name)"
}
} else {
$preserveSubdirs = @()
if ($mapping.Keys -contains "preserveSubdirs") {
$preserveSubdirs = $mapping.preserveSubdirs
}
$savedDirs = @{}
if ($preserveSubdirs.Count -gt 0 -and (Test-Path $targetPath)) {
foreach ($sub in $preserveSubdirs) {
$subPath = Join-Path $targetPath $sub
if (Test-Path $subPath -PathType Container) {
$tmpPath = Join-Path $extractDir "_preserve_$sub"
Move-Item -Path $subPath -Destination $tmpPath -Force
$savedDirs[$sub] = $tmpPath
Write-Log "Preserved subdirectory for clean update: $subPath"
}
}
}
# ── File-by-file replacement (avoids WinError 32 on locked exe) ───
# Previously we used Remove-Item -Recurse + Copy-Item -Recurse, which
# fails when a running exe lives inside the target directory (WinError 32).
# Now we replace files one-by-one: skip locked files, save pending copies,
# and remove stale files that aren't in the new version.
if (-not (Test-Path $targetPath)) {
New-Item -ItemType Directory -Path $targetPath -Force | Out-Null
}
# Build set of relative paths in the NEW version
$newFiles = @{}
Get-ChildItem -Path $extractDir -Recurse | Where-Object { $_.Name -notlike '_preserve_*' } | ForEach-Object {
$rel = $_.FullName.Substring($extractDir.Length).TrimStart('\')
if ($rel) { $newFiles[$rel] = $_ }
}
# Remove stale files/dirs from old version (not in new version)
if (Test-Path $targetPath) {
Get-ChildItem -Path $targetPath -Recurse | Sort-Object { $_.FullName.Length } -Descending | ForEach-Object {
$rel = $_.FullName.Substring($targetPath.Length).TrimStart('\')
if ($rel -and -not $newFiles.ContainsKey($rel)) {
# Skip locked files (e.g. running api_server.exe)
try {
if ($_.PSIsContainer) {
# Only remove empty directories not in new version
if (-not (Get-ChildItem -Path $_.FullName -ErrorAction SilentlyContinue)) {
Remove-Item -Path $_.FullName -Force -ErrorAction Stop
Write-Log "Removed stale dir: $rel"
}
} else {
Remove-Item -Path $_.FullName -Force -ErrorAction Stop
Write-Log "Removed stale file: $rel"
}
} catch {
Write-Log "WARNING: Could not remove stale $rel — $($_.Exception.Message)"
}
}
}
}
# Copy new files one-by-one (skip _preserve_* temp dirs)
$exeDeferred = $false
Get-ChildItem -Path $extractDir -Recurse -File | Where-Object {
$_.FullName -notmatch '_preserve_'
} | ForEach-Object {
$rel = $_.FullName.Substring($extractDir.Length).TrimStart('\')
$dest = Join-Path $targetPath $rel
# Ensure parent directory exists
$destDir = Split-Path -Parent $dest
if (-not (Test-Path $destDir)) {
New-Item -ItemType Directory -Path $destDir -Force | Out-Null
}
# Check if destination is a locked exe (api_server.exe still running?)
$destFile = Get-Item $dest -ErrorAction SilentlyContinue
if ($destFile -and $destFile.Name -eq 'api_server.exe') {
# Try to overwrite — if it's locked, defer to launcher
try {
Copy-Item -Path $_.FullName -Destination $dest -Force -ErrorAction Stop
Write-Log "Updated file: $rel"
} catch {
# Locked exe — save pending copy for launcher deferred replacement
$pendingExe = Join-Path $UpdateDir "_pending_new_api_server.exe"
$pendingFlag = Join-Path $UpdateDir "_exe_pending_replace"
Copy-Item -Path $_.FullName -Destination $pendingExe -Force
[System.IO.File]::WriteAllText($pendingFlag, "pending", (New-Object System.Text.UTF8Encoding($false)))
$exeDeferred = $true
Write-Log "DEFERRED exe replacement: saved new exe to $pendingExe (file was locked)"
}
} else {
try {
Copy-Item -Path $_.FullName -Destination $dest -Force -ErrorAction Stop
Write-Log "Updated file: $rel"
} catch {
Write-Log "WARNING: Could not copy $rel — $($_.Exception.Message)"
}
}
}
# Restore preserved subdirectories
foreach ($sub in $savedDirs.Keys) {
$restorePath = Join-Path $targetPath $sub
Move-Item -Path $savedDirs[$sub] -Destination $restorePath -Force
Write-Log "Restored preserved subdirectory: $restorePath"
}
Write-Log "Updated directory (file-by-file): $targetPath"
if ($exeDeferred) {
Write-Log "NOTE: api_server.exe replacement deferred — launcher will swap on next startup"
}
}
}
# Clean up extract dir
Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue
$appliedCount++
} catch {
Write-Log "ERROR: Failed to apply $name - $($_.Exception.Message)"
$errorCount++
# Attempt rollback for this component
$backupTarget = Join-Path $backupDir $name
if (Test-Path $backupTarget) {
try {
if ($mapping.type -eq "dir" -and $mapping.target -ne ".") {
# Never delete the update dir itself — it contains the backup and running scripts.
# Restore in-place instead.
if ($targetPath -eq $UpdateDir) {
Get-ChildItem -Path $backupTarget | ForEach-Object {
Copy-Item -Path $_.FullName -Destination $targetPath -Recurse -Force
}
} else {
if (Test-Path $targetPath) { Remove-Item $targetPath -Recurse -Force }
Copy-Item -Path $backupTarget -Destination $targetPath -Recurse -Force
}
Write-Log "Rolled back $name from backup"
}
} catch {
Write-Log "ERROR: Rollback failed for $name - $($_.Exception.Message)"
}
}
}
}
# Update local version.json
if ($appliedCount -gt 0) {
try {
$newLocal = Get-LocalVersion
$newLocal.version = $remote.version
$newLocal.buildDate = $remote.buildDate
foreach ($prop in $remote.components.PSObject.Properties) {
$name = $prop.Name
$zipFile = Join-Path $StagedDir "$name.zip"
if (Test-Path $zipFile) {
if (-not $newLocal.components.PSObject.Properties[$name]) {
$newLocal.components | Add-Member -MemberType NoteProperty -Name $name -Value @{}
}
$newLocal.components.$name.version = $prop.Value.version
}
}
$newLocal | ConvertTo-Json -Depth 5 | ForEach-Object { Write-Utf8File $LocalVersionFile $_ }
Write-Log "Updated local version.json to v$($remote.version)"
} catch {
Write-Log "WARNING: could not update local version.json - $($_.Exception.Message)"
}
}
# Clean up staging
Remove-Item $PendingFlag -Force -ErrorAction SilentlyContinue
Remove-Item $StagedDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Progress-File
# Clean old backups (keep last 3)
$oldBackups = Get-ChildItem -Path $UpdateDir -Directory -Filter "_backup_*" |
Sort-Object Name -Descending |
Select-Object -Skip 3
foreach ($old in $oldBackups) {
Remove-Item $old.FullName -Recurse -Force -ErrorAction SilentlyContinue
Write-Log "Removed old backup: $($old.Name)"
}
$result = @{
applied = ($appliedCount -gt 0)
components = $appliedCount
errors = $errorCount
newVersion = $remote.version
} | ConvertTo-Json -Compress
Write-Output $result
if ($Restart -and $appliedCount -gt 0) {
Write-Log "Restart requested - launching MEC Agent..."
$launcher = Join-Path $InstallRoot "launcher\launch_mec_agent.vbs"
if (Test-Path $launcher) {
Start-Process "wscript.exe" -ArgumentList "`"$launcher`"" -WorkingDirectory $InstallRoot
} else {
$ps1 = Join-Path $InstallRoot "launcher\launch_mec_agent.ps1"
if (Test-Path $ps1) {
Start-Process "powershell.exe" -ArgumentList "-ExecutionPolicy", "Bypass", "-File", $ps1 -WorkingDirectory $InstallRoot
}
}
}
exit 0
}
# If no mode specified, show usage
Write-Host @"
Usage:
mec_updater.ps1 -Check [-ManifestUrl <url>] — Check for available updates (JSON output)
mec_updater.ps1 -Stage [-ManifestUrl <url>] — Download available updates to staging area
mec_updater.ps1 -ApplyStaged [-Restart] — Apply staged updates and optionally restart
ManifestUrl can also be configured in update/update_config.json -> manifestUrl
"@
Comments...
No Comments Yet...