mec_updater.ps1

2026-06-29




<#

.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

"@

 







Login to like - 0 Likes



Comments...


No Comments Yet...



Add Comment...



shumin

A graduated biotechnology engineer. Now is a software engineer


Latest Posts



Footer with Icons