#requires -Version 5.1 [CmdletBinding()] param( [ValidateSet('Install', 'Doctor', 'Rollback', 'Uninstall')][string]$Action = 'Install', [string]$BaseUrl, [string]$Version, [string[]]$Clients = @('generic'), [string]$InstallRoot = (Join-Path $env:LOCALAPPDATA 'Programs/8XAI-MCP'), [string]$AssetRoot, [string]$DataRoot, [string]$ConfigRoot, [switch]$AllowLoopbackHttp ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2 $utf8 = New-Object System.Text.UTF8Encoding($false) $product = '8xai-design-mcp' $serverName = '8xai-design' $InstallRoot = [IO.Path]::GetFullPath($InstallRoot) $bound = $PSBoundParameters function Write-AtomicBytes([string]$Path, [byte[]]$Bytes) { [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($Path)) | Out-Null $temp = $Path + '.' + [guid]::NewGuid().ToString('N') + '.tmp' try { [IO.File]::WriteAllBytes($temp, $Bytes) if ([IO.File]::Exists($Path)) { [IO.File]::Replace($temp, $Path, [System.Management.Automation.Language.NullString]::Value) } else { [IO.File]::Move($temp, $Path) } } finally { if ([IO.File]::Exists($temp)) { [IO.File]::Delete($temp) } } } function Write-Json([string]$Path, $Value) { Write-AtomicBytes $Path ($utf8.GetBytes(($Value | ConvertTo-Json -Depth 100))) } function Read-Json([string]$Path) { return [IO.File]::ReadAllText($Path, $utf8) | ConvertFrom-Json } function File-Sha256([string]$Path) { $algorithm = [Security.Cryptography.SHA256]::Create() $stream = [IO.File]::OpenRead($Path) try { return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace('-', '').ToLowerInvariant() } finally { $stream.Dispose(); $algorithm.Dispose() } } function Assert-Version([string]$Value) { if ($Value -notmatch '^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$') { throw 'Invalid release version' } } function Remove-OwnedTree([string]$Path) { $full = [IO.Path]::GetFullPath($Path) if (-not $full.StartsWith($InstallRoot.TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Cleanup path is outside the installation directory' } if (Test-Path -LiteralPath $full) { Remove-Item -LiteralPath $full -Recurse -Force } } function Assert-DownloadUrl([uri]$Uri) { if (-not $Uri.IsAbsoluteUri -or $Uri.UserInfo -or $Uri.Query -or $Uri.Fragment) { throw 'Use an absolute download URL without credentials or query parameters' } if ($Uri.Scheme -eq 'https') { return } if ($AllowLoopbackHttp -and $Uri.Scheme -eq 'http' -and $Uri.IsLoopback) { return } throw 'Downloads require HTTPS; HTTP is only allowed for explicit loopback tests' } function Download([string]$Url, [string]$Destination, [long]$MaxBytes) { $uri = [uri]$Url for ($redirects = 0; $redirects -lt 6; $redirects++) { Assert-DownloadUrl $uri $request = [Net.HttpWebRequest]::Create($uri) $request.AllowAutoRedirect = $false $request.Timeout = 30000 $request.ReadWriteTimeout = 120000 $response = $request.GetResponse() try { if ([int]$response.StatusCode -ge 300 -and [int]$response.StatusCode -lt 400) { $uri = [uri]::new($uri, $response.Headers['Location']) continue } if ($response.ContentLength -gt $MaxBytes) { throw 'Download exceeds the declared size limit' } $inputStream = $response.GetResponseStream() $outputStream = [IO.File]::Create($Destination) try { $buffer = New-Object byte[] 65536 [long]$total = 0 while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { $total += $count if ($total -gt $MaxBytes) { throw 'Download exceeds the declared size limit' } $outputStream.Write($buffer, 0, $count) } } finally { $outputStream.Dispose(); $inputStream.Dispose() } return } finally { $response.Dispose() } } throw 'Too many download redirects' } function Assert-Manifest($Manifest) { Assert-Version $Manifest.version if ($Manifest.schemaVersion -ne 1 -or $Manifest.product -ne $product -or $Manifest.platform -ne 'windows-x64' -or $Manifest.artifact.file -ne '8xai-design-mcp.exe' -or $Manifest.artifact.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $Manifest.artifact.size -lt 64 -or $Manifest.artifact.size -gt 1073741824) { throw 'Invalid release manifest' } } function Assert-Artifact([string]$Exe, $Manifest) { Assert-Manifest $Manifest if ((Get-Item -LiteralPath $Exe).Length -ne $Manifest.artifact.size -or (File-Sha256 $Exe) -ne $Manifest.artifact.sha256) { throw 'Executable size or SHA256 mismatch' } } function Install-UsageGuidance([string]$Release, $Manifest) { if (-not $Manifest.PSObject.Properties['guidance']) { return } $item = $Manifest.guidance if ($item.file -ne ('8xai-design-guide-' + $Manifest.version + '.zip') -or $item.sha256 -notmatch '^[a-fA-F0-9]{64}$' -or $item.size -lt 1 -or $item.size -gt 2097152) { throw 'Invalid usage skill manifest' } $zipPath = Join-Path $Release $item.file Download ($BaseUrl + '/releases/' + $Manifest.version + '/' + $item.file) $zipPath ([long]$item.size) if ((Get-Item -LiteralPath $zipPath).Length -ne $item.size -or (File-Sha256 $zipPath) -ne $item.sha256) { throw 'Usage skill hash or size mismatch' } Add-Type -AssemblyName System.IO.Compression.FileSystem $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) try { $seen = @{} [long]$total = 0 if ($archive.Entries.Count -lt 1 -or $archive.Entries.Count -gt 100) { throw 'Invalid usage skill archive' } foreach ($part in $archive.Entries) { $name = $part.FullName $total += $part.Length if ($name -cnotmatch '^8xai-design-guide/(?:[a-z0-9_-]+/)*(?:SKILL|[a-z0-9_-]+)\.md$' -or $seen.ContainsKey($name) -or $total -gt 2097152) { throw 'Unsafe usage skill archive' } $seen[$name] = $true } if (-not $seen.ContainsKey('8xai-design-guide/SKILL.md')) { throw 'Usage skill entry missing' } $destination = Join-Path $Release 'guidance' foreach ($part in $archive.Entries) { $path = [IO.Path]::GetFullPath((Join-Path $destination $part.FullName)) if (-not $path.StartsWith([IO.Path]::GetFullPath($destination).TrimEnd('\') + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe usage skill path' } [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($path)) | Out-Null [IO.Compression.ZipFileExtensions]::ExtractToFile($part, $path, $false) } } finally { $archive.Dispose() } } function Invoke-LocalDoctor([string]$Exe) { $scratch = Join-Path $InstallRoot ('.selftest-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($scratch) | Out-Null $process = New-Object Diagnostics.Process $process.StartInfo.FileName = $Exe $process.StartInfo.UseShellExecute = $false $process.StartInfo.CreateNoWindow = $true $process.StartInfo.RedirectStandardInput = $true $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true $process.StartInfo.StandardOutputEncoding = $utf8 $process.StartInfo.StandardErrorEncoding = $utf8 $process.StartInfo.WorkingDirectory = $scratch foreach ($key in @('XAI_API_TOKEN', 'XAI_TABS_FILE', 'XAI_ASSET_ROOT', 'XAI_DATA_DIR')) { $process.StartInfo.EnvironmentVariables.Remove($key) } $process.StartInfo.EnvironmentVariables['XAI_DATA_DIR'] = $scratch $process.StartInfo.EnvironmentVariables['XAI_ASSET_ROOT'] = $scratch $started = $false try { if (-not $process.Start()) { throw 'Cannot start installed MCP executable' } $started = $true $stderr = $process.StandardError.ReadToEndAsync() $process.StandardInput.NewLine = "`n" function Exchange($Body) { $process.StandardInput.WriteLine(($Body | ConvertTo-Json -Depth 20 -Compress)) $process.StandardInput.Flush() $deadline = [DateTime]::UtcNow.AddSeconds(60) for ($lines = 0; $lines -lt 100; $lines++) { $remaining = [int][Math]::Max(1, ($deadline - [DateTime]::UtcNow).TotalMilliseconds) $pending = $process.StandardOutput.ReadLineAsync() if (-not $pending.Wait($remaining)) { throw 'MCP self-test timed out' } $line = $pending.Result if ($null -eq $line -or $line.Length -gt 4194304) { throw 'Invalid MCP stdout framing' } $message = $line | ConvertFrom-Json if ($message.PSObject.Properties['id'] -and $message.id -eq $Body.id) { if ($message.PSObject.Properties['error']) { throw 'MCP self-test returned a protocol error' } return $message.result } if ([DateTime]::UtcNow -ge $deadline) { throw 'MCP self-test timed out' } } throw 'MCP self-test response not found' } $initialized = Exchange @{jsonrpc='2.0';id=1;method='initialize';params=@{protocolVersion='2025-11-25';capabilities=@{};clientInfo=@{name='8xai-installer-doctor';version='1'}}} if ($initialized.serverInfo.name -ne '8xai-design') { throw 'Unexpected MCP server identity' } $process.StandardInput.WriteLine('{"jsonrpc":"2.0","method":"notifications/initialized"}') $tools = Exchange @{jsonrpc='2.0';id=2;method='tools/list';params=@{}} if ('batch_plan' -notin @($tools.tools.name) -or 'results_save' -notin @($tools.tools.name)) { throw 'Required MCP tools missing' } $catalog = Exchange @{jsonrpc='2.0';id=3;method='tools/call';params=@{name='tabs_list';arguments=@{}}} if ($catalog.PSObject.Properties['isError'] -and $catalog.isError) { throw 'MCP catalog failed' } $plan = Exchange @{jsonrpc='2.0';id=4;method='tools/call';params=@{name='batch_plan';arguments=@{tab='full_gen';variant='full_gen.regular';params=@{clothingImages=@('installer-selftest.png');outputCount=1}}}} if (($plan.PSObject.Properties['isError'] -and $plan.isError) -or $plan.structuredContent.status -ne 'planned') { throw 'Offline MCP preview failed' } return @{status='passed';checks=@('initialize', 'tools/list', 'tabs_list', 'batch_plan');analysisRequests=0;generationRequests=0;isolatedData=$true} } finally { try { if ($started -and -not $process.HasExited) { $process.StandardInput.Close() if (-not $process.WaitForExit(3000)) { $process.Kill(); $process.WaitForExit() } } } finally { $process.Dispose(); Remove-OwnedTree $scratch } } } function Config-Path([string]$Client) { if ($ConfigRoot) { switch ($Client) { 'claude' { return Join-Path $ConfigRoot 'Claude/claude_desktop_config.json' } 'cursor' { return Join-Path $ConfigRoot '.cursor/mcp.json' } 'codex' { return Join-Path $ConfigRoot '.codex/config.toml' } } } switch ($Client) { 'claude' { return Join-Path $env:APPDATA 'Claude/claude_desktop_config.json' } 'cursor' { return Join-Path $env:USERPROFILE '.cursor/mcp.json' } 'codex' { $codexDirectory = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE '.codex' } return Join-Path $codexDirectory 'config.toml' } } } function Codex-Block($Entry) { $command = ConvertTo-Json ([string]$Entry.command) -Compress $assets = ConvertTo-Json ([string]$Entry.env.XAI_ASSET_ROOT) -Compress $data = ConvertTo-Json ([string]$Entry.env.XAI_DATA_DIR) -Compress return "# BEGIN 8XAI MCP managed`r`n[mcp_servers.$serverName]`r`ncommand = $command`r`nargs = []`r`nstartup_timeout_sec = 60`r`ntool_timeout_sec = 900`r`n[mcp_servers.$serverName.env]`r`nXAI_ASSET_ROOT = $assets`r`nXAI_DATA_DIR = $data`r`n# END 8XAI MCP managed`r`n" } function Updated-Config([string]$Client, [string]$Text, $Entry, [bool]$Remove) { if ($Client -eq 'codex') { $pattern = '(?ms)^# BEGIN 8XAI MCP managed\r?\n.*?^# END 8XAI MCP managed(?:\r?\n|$)' $matches = [regex]::Matches($Text, $pattern) if ($matches.Count -gt 1) { throw 'Multiple managed Codex blocks; inspect configuration first' } $rest = [regex]::Replace($Text, $pattern, '') if ($rest -match '(?m)^\s*\[\s*mcp_servers\s*\.\s*["'']?8xai-design["'']?\s*[.\]]' -or $rest -match '(?m)^\s*["'']?8xai-design["'']?\s*=') { throw 'Existing unmanaged Codex entry; merge exported codex.toml manually' } if ($Remove) { return $rest } return $rest.TrimEnd() + "`r`n`r`n" + (Codex-Block $Entry) } $config = if ($Text.Trim()) { $Text | ConvertFrom-Json } else { [pscustomobject]@{} } if ($null -eq $config -or $config -is [array] -or $config -is [string] -or $config -is [ValueType]) { throw 'Client config must be a JSON object' } if (-not $config.PSObject.Properties['mcpServers']) { $config | Add-Member NoteProperty mcpServers ([pscustomobject]@{}) } if ($null -eq $config.mcpServers -or $config.mcpServers -is [array] -or $config.mcpServers -is [string] -or $config.mcpServers -is [ValueType]) { throw 'mcpServers must be a JSON object' } if ($Remove) { $config.mcpServers.PSObject.Properties.Remove($serverName) } else { $clientEntry = $Entry if ($Client -eq 'cursor') { $clientEntry = [pscustomobject]@{type='stdio';command=$Entry.command;args=@();env=$Entry.env} } $config.mcpServers | Add-Member -Force NoteProperty $serverName $clientEntry } return $config | ConvertTo-Json -Depth 100 } if (-not [Environment]::Is64BitOperatingSystem) { throw 'Windows x64 is required' } [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 [IO.Directory]::CreateDirectory($InstallRoot) | Out-Null $lock = $null $stage = $null $published = $false $publishedPath = $null $snapshots = @{} $statePath = Join-Path $InstallRoot 'current.json' try { $lock = [IO.File]::Open((Join-Path $InstallRoot '.install.lock'), [IO.FileMode]::OpenOrCreate, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) $old = if (Test-Path -LiteralPath $statePath) { Read-Json $statePath } else { $null } if ($old -and $old.product -ne $product) { throw 'Installation directory belongs to another product' } if ($old) { if (-not $bound.ContainsKey('AssetRoot')) { $AssetRoot = $old.assetRoot } if (-not $bound.ContainsKey('DataRoot')) { $DataRoot = $old.dataRoot } if (-not $bound.ContainsKey('ConfigRoot')) { $ConfigRoot = $old.configRoot } elseif ($ConfigRoot -ne $old.configRoot) { throw 'Use the original client configuration root for this installation' } $Clients = @($old.clients) + $(if ($bound.ContainsKey('Clients')) { $Clients } else { @() }) } $Clients = @($Clients | ForEach-Object { $_ -split ',' } | Select-Object -Unique) if ('all' -in $Clients) { $Clients = @('claude', 'cursor', 'codex', 'cherry', 'generic') } if (@($Clients | Where-Object { $_ -notin @('claude', 'cursor', 'codex', 'cherry', 'generic') }).Count) { throw 'Unknown client; use claude, cursor, codex, cherry, generic or all' } if (-not $AssetRoot) { $AssetRoot = Join-Path $env:USERPROFILE 'Pictures/8XAI/素材' } if (-not $DataRoot) { $DataRoot = Join-Path $env:USERPROFILE '.8xai-mcp' } $AssetRoot = [IO.Path]::GetFullPath($AssetRoot) $DataRoot = [IO.Path]::GetFullPath($DataRoot) if ($Action -ne 'Install' -and -not $old) { throw 'No managed installation found' } if ($Action -eq 'Doctor') { $manifest = Read-Json (Join-Path $InstallRoot ('versions/' + $old.version + '/receipt.json')) Assert-Artifact $old.executable $manifest $doctor = Invoke-LocalDoctor $old.executable Write-Json (Join-Path $InstallRoot 'latest-doctor.json') $doctor Write-Output ($doctor | ConvertTo-Json -Compress) return } $entry = $null $manifest = $null $doctor = $null if ($Action -ne 'Uninstall') { if ($Action -eq 'Rollback') { if (-not $old.previousVersion) { throw 'No previous version is available' } $Version = $old.previousVersion Assert-Version $Version $releasePath = Join-Path $InstallRoot ('versions/' + $Version) $manifest = Read-Json (Join-Path $releasePath 'receipt.json') } else { if (-not $BaseUrl) { throw 'Install requires -BaseUrl for the HTTPS download site' } Assert-DownloadUrl ([uri]$BaseUrl) $BaseUrl = $BaseUrl.TrimEnd('/') $stage = Join-Path $InstallRoot ('.staging-' + [guid]::NewGuid().ToString('N')) [IO.Directory]::CreateDirectory($stage) | Out-Null $expectedManifestHash = $null if (-not $Version) { Download ($BaseUrl + '/channels/stable.json') (Join-Path $stage 'channel.json') 262144 $channel = Read-Json (Join-Path $stage 'channel.json') $Version = $channel.version Assert-Version $Version if ($channel.schemaVersion -ne 1 -or $channel.manifest -ne ('releases/' + $Version + '/manifest.json') -or $channel.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw 'Invalid stable channel' } $expectedManifestHash = $channel.sha256 } Assert-Version $Version Download ($BaseUrl + '/releases/' + $Version + '/manifest.json') (Join-Path $stage 'manifest.json') 262144 if ($expectedManifestHash -and (File-Sha256 (Join-Path $stage 'manifest.json')) -ne $expectedManifestHash) { throw 'Release manifest SHA256 mismatch' } $manifest = Read-Json (Join-Path $stage 'manifest.json') Assert-Manifest $manifest if ($manifest.version -ne $Version -or $manifest.artifact.file -ne '8xai-design-mcp.exe') { throw 'Release manifest version or artifact mismatch' } $releasePath = Join-Path $InstallRoot ('versions/' + $Version) if (Test-Path -LiteralPath $releasePath) { $receipt = Read-Json (Join-Path $releasePath 'receipt.json') if ($receipt.artifact.sha256 -ne $manifest.artifact.sha256) { throw 'An immutable installed version has different contents' } } else { Download ($BaseUrl + '/releases/' + $Version + '/8xai-design-mcp.exe') (Join-Path $stage '8xai-design-mcp.exe') ([long]$manifest.artifact.size) Assert-Artifact (Join-Path $stage '8xai-design-mcp.exe') $manifest Install-UsageGuidance $stage $manifest Write-Json (Join-Path $stage 'receipt.json') $manifest [IO.Directory]::CreateDirectory((Join-Path $InstallRoot 'versions')) | Out-Null [IO.Directory]::Move($stage, $releasePath) $stage = $null $published = $true $publishedPath = $releasePath } } $exe = Join-Path $releasePath '8xai-design-mcp.exe' if ($manifest.version -ne $Version) { throw 'Installed receipt version mismatch' } Assert-Artifact $exe $manifest $doctor = Invoke-LocalDoctor $exe $entry = [pscustomobject]@{command=$exe;args=@();env=@{XAI_ASSET_ROOT=$AssetRoot;XAI_DATA_DIR=$DataRoot}} } $changes = @() foreach ($client in @($Clients | Where-Object { $_ -in @('claude', 'cursor', 'codex') })) { $path = [IO.Path]::GetFullPath((Config-Path $client)) $text = if (Test-Path -LiteralPath $path) { [IO.File]::ReadAllText($path, $utf8) } else { '' } if ($Action -eq 'Uninstall' -and $client -eq 'codex') { $blocks = [regex]::Matches($text, '(?ms)^# BEGIN 8XAI MCP managed\r?\n.*?^# END 8XAI MCP managed(?:\r?\n|$)') $oldEntry = [pscustomobject]@{command=$old.executable;env=@{XAI_ASSET_ROOT=$old.assetRoot;XAI_DATA_DIR=$old.dataRoot}} if ($blocks.Count -eq 1 -and $blocks[0].Value.Replace("`r`n", "`n").Trim() -ne (Codex-Block $oldEntry).Replace("`r`n", "`n").Trim()) { throw 'Codex managed entry was changed outside this installer; preserving it' } } if ($Action -eq 'Uninstall' -and $client -ne 'codex' -and $text.Trim()) { $existing = $text | ConvertFrom-Json if ($existing.PSObject.Properties['mcpServers'] -and $existing.mcpServers.PSObject.Properties[$serverName] -and $existing.mcpServers.$serverName.command -ne $old.executable) { throw 'Client entry was changed outside this installer; preserving it' } } $next = Updated-Config $client $text $entry ($Action -eq 'Uninstall') $changes += @{path=$path;bytes=$utf8.GetBytes($next)} } $exports = Join-Path $InstallRoot 'client-configs' if ($Action -ne 'Uninstall') { $generic = @{mcpServers=@{$serverName=$entry}} $changes += @{path=(Join-Path $exports 'mcp.json');bytes=$utf8.GetBytes(($generic | ConvertTo-Json -Depth 20))} $changes += @{path=(Join-Path $exports 'codex.toml');bytes=$utf8.GetBytes((Codex-Block $entry))} $changes += @{path=(Join-Path $exports 'cherry.json');bytes=$utf8.GetBytes(($generic | ConvertTo-Json -Depth 20))} } $backup = Join-Path $InstallRoot ('backups/' + [guid]::NewGuid().ToString('N')) $backupIndex = @() foreach ($change in @($changes) + @(@{path=$statePath})) { $path = $change.path if (Test-Path -LiteralPath $path) { $snapshots[$path] = [IO.File]::ReadAllBytes($path) } else { $snapshots[$path] = $null } if ($null -ne $snapshots[$path]) { [IO.Directory]::CreateDirectory($backup) | Out-Null $backupName = [guid]::NewGuid().ToString('N') + '.bak' [IO.File]::WriteAllBytes((Join-Path $backup $backupName), $snapshots[$path]) $backupIndex += @{original=$path;backup=$backupName} } } if ($backupIndex.Count) { Write-Json (Join-Path $backup 'index.json') @{files=$backupIndex} } foreach ($change in $changes) { Write-AtomicBytes $change.path $change.bytes } if ($Action -eq 'Uninstall') { # Credentials, materials, backups and immutable versions are intentionally retained. # Removing registration and the active pointer is reversible, even when a client holds the exe open. [IO.File]::Delete($statePath) Write-Output 'MCP unregistered; user data and retained versions preserved.' } else { [IO.Directory]::CreateDirectory($AssetRoot) | Out-Null $previous = if ($old -and $old.version -ne $Version) { $old.version } elseif ($old) { $old.previousVersion } else { $null } Write-Json $statePath @{schemaVersion=1;product=$product;version=$Version;previousVersion=$previous;executable=$exe; clients=@($Clients);configRoot=$ConfigRoot;assetRoot=$AssetRoot;dataRoot=$DataRoot;doctor=$doctor} Write-Output ("Installed {0}; MCP offline self-test passed. Config exports: {1}" -f $Version, $exports) Write-Output ("Usage skill: {0}. Restart your MCP client, then ask: 请用八象引导我处理图片。" -f (Join-Path $releasePath 'guidance/8xai-design-guide')) } } catch { $failure = $_ $restoreFailures = @() foreach ($path in $snapshots.Keys) { try { if ($null -eq $snapshots[$path]) { if (Test-Path -LiteralPath $path) { [IO.File]::Delete($path) } } else { $unchanged = $false if ([IO.File]::Exists($path)) { $unchanged = [Convert]::ToBase64String([IO.File]::ReadAllBytes($path)) -eq [Convert]::ToBase64String($snapshots[$path]) } if (-not $unchanged) { Write-AtomicBytes $path $snapshots[$path] } } } catch { $restoreFailures += $path } } if ($published -and $publishedPath -and -not $restoreFailures.Count) { Remove-OwnedTree $publishedPath } if ($restoreFailures.Count) { throw ('Installation failed; restore these configs from backups: ' + ($restoreFailures -join ', ')) } throw $failure } finally { if ($stage) { Remove-OwnedTree $stage } if ($lock) { $lock.Dispose() } }