param( [Parameter(Mandatory=$true)][string]$SourcePath, [Parameter(Mandatory=$true)][string]$ArchivePath, [Parameter(Mandatory=$true)][string]$ExpectedVersion, [Parameter(Mandatory=$true)][string]$ExpectedSha256, [Parameter(Mandatory=$true)][int]$ParentPid, [Parameter(Mandatory=$true)][string]$LogPath, [Parameter(Mandatory=$true)][string]$StatusPath, [Parameter(Mandatory=$true)][string]$UpdateId, [switch]$HandshakeOnly ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $working = $null $backup = $null function Write-UpdateLog { param([string]$Message) $line = "$(Get-Date -Format o) $Message" $directory = Split-Path -Parent $LogPath if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } function Write-UpdateState { param( [Parameter(Mandatory=$true)][string]$State, [string]$Message = "", [hashtable]$Extra = @{} ) $payload = [ordered]@{ schemaVersion = 1 updateId = $UpdateId state = $State expectedVersion = $ExpectedVersion sourcePath = $SourcePath logPath = $LogPath statusPath = $StatusPath message = $Message updatedAt = (Get-Date).ToUniversalTime().ToString("o") } foreach ($key in $Extra.Keys) { $payload[$key] = $Extra[$key] } $directory = Split-Path -Parent $StatusPath if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null } $temporary = "$StatusPath.$PID.tmp" $json = $payload | ConvertTo-Json -Depth 8 $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($temporary, $json, $utf8NoBom) try { if ([System.IO.File]::Exists($StatusPath)) { # Windows PowerShell 5.1 does not reliably let Move-Item -Force replace # an existing file. File.Replace is atomic on the local NTFS volume. $backup = "$StatusPath.$PID.bak" [System.IO.File]::Replace($temporary, $StatusPath, $backup) } else { [System.IO.File]::Move($temporary, $StatusPath) } } catch { # Some filesystems do not implement File.Replace. Copy with overwrite is # the deterministic fallback; the temporary file is removed afterwards. if ([System.IO.File]::Exists($temporary)) { [System.IO.File]::Copy($temporary, $StatusPath, $true) [System.IO.File]::Delete($temporary) } } finally { if ([System.IO.File]::Exists($backup)) { [System.IO.File]::Delete($backup) } } } function Invoke-Robocopy { param([string]$From, [string]$To) New-Item -ItemType Directory -Force -Path $To | Out-Null & robocopy.exe $From $To /MIR /R:2 /W:1 /NFL /NDL /NJH /NJS /NP /XD node_modules .git dist | Out-Null if ($LASTEXITCODE -gt 7) { throw "robocopy failed with exit code $LASTEXITCODE" } } function Install-ForgeFlowDependencies { param([string]$WorkingDirectory) Push-Location $WorkingDirectory try { if (Test-Path -LiteralPath (Join-Path $WorkingDirectory "package-lock.json")) { Write-UpdateLog "Installing dependencies from package-lock.json with npm ci." & cmd.exe /d /s /c "npm ci --no-audit --no-fund" *>> $LogPath if ($LASTEXITCODE -ne 0) { throw "npm ci failed with exit code $LASTEXITCODE." } } else { Write-UpdateLog "No package-lock.json was supplied; installing pinned direct dependencies with npm install." & cmd.exe /d /s /c "npm install --no-audit --no-fund" *>> $LogPath if ($LASTEXITCODE -ne 0) { throw "npm install failed with exit code $LASTEXITCODE." } } } finally { Pop-Location } } function Start-ForgeFlow { param([string]$WorkingDirectory) $electron = Join-Path $WorkingDirectory "node_modules\electron\dist\electron.exe" if (-not (Test-Path -LiteralPath $electron)) { throw "electron.exe was not found after dependency installation." } $process = Start-Process -FilePath $electron -WorkingDirectory $WorkingDirectory -ArgumentList @(".") -PassThru Start-Sleep -Milliseconds 1200 if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application window could start." } return $process } try { Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion." Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") } if ($HandshakeOnly) { Write-UpdateLog "Handshake-only verification completed successfully." exit 0 } Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit." $deadline = (Get-Date).AddMinutes(2) while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) { if ((Get-Date) -gt $deadline) { throw "ForgeFlow did not exit before the update timeout." } Start-Sleep -Milliseconds 500 } $actualHash = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." } $working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N")) $extract = Join-Path $working "extract" $backup = Join-Path $working "backup" New-Item -ItemType Directory -Force -Path $extract | Out-Null Write-UpdateLog "Creating source backup." Write-UpdateState -State "backing-up" -Message "Creating a restorable backup of the current source." Invoke-Robocopy -From $SourcePath -To $backup Write-UpdateLog "Extracting update archive." Write-UpdateState -State "extracting" -Message "Extracting the verified update archive." Expand-Archive -LiteralPath $ArchivePath -DestinationPath $extract -Force $manifest = Get-ChildItem -Path $extract -Filter package.json -File -Recurse | Where-Object { try { $json = Get-Content $_.FullName -Raw | ConvertFrom-Json return $json.name -eq "forgeflow" -and $json.version -eq $ExpectedVersion } catch { return $false } } | Select-Object -First 1 if (-not $manifest) { throw "The update does not contain ForgeFlow version $ExpectedVersion." } $incoming = Split-Path -Parent $manifest.FullName Write-UpdateLog "Applying verified source files." Write-UpdateState -State "applying" -Message "Replacing the local source with ForgeFlow $ExpectedVersion." Invoke-Robocopy -From $incoming -To $SourcePath Write-UpdateState -State "validating" -Message "Installing dependencies and running the complete quality gate." Install-ForgeFlowDependencies -WorkingDirectory $SourcePath Push-Location $SourcePath try { Write-UpdateLog "Running ForgeFlow quality gate." & cmd.exe /d /s /c "npm run check" *>> $LogPath if ($LASTEXITCODE -ne 0) { throw "npm run check failed with exit code $LASTEXITCODE." } } finally { Pop-Location } $completedAt = (Get-Date).ToUniversalTime().ToString("o") Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully." -Extra @{ installedVersion = $ExpectedVersion completedAt = $completedAt restartLaunched = $true restartPid = $null restartError = $null } try { $restart = Start-ForgeFlow -WorkingDirectory $SourcePath Write-UpdateLog "Update validated successfully. ForgeFlow was restarted directly with Electron PID $($restart.Id)." Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed and restarted successfully." -Extra @{ installedVersion = $ExpectedVersion completedAt = $completedAt restartLaunched = $true restartPid = $restart.Id restartError = $null } } catch { $restartError = $_.Exception.Message Write-UpdateLog "Update validated successfully, but automatic restart failed: $restartError" Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion was installed successfully, but must be started manually." -Extra @{ installedVersion = $ExpectedVersion completedAt = $completedAt restartLaunched = $false restartPid = $null restartError = $restartError } } if ($working) { Remove-Item -LiteralPath $working -Recurse -Force -ErrorAction SilentlyContinue } exit 0 } catch { $failureMessage = $_.Exception.Message try { Write-UpdateLog ("Update failed: " + $failureMessage) } catch {} try { Write-UpdateState -State "failed" -Message $failureMessage -Extra @{ failedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} try { if ($backup -and (Test-Path -LiteralPath $backup)) { Write-UpdateLog "Restoring previous source version." Invoke-Robocopy -From $backup -To $SourcePath Install-ForgeFlowDependencies -WorkingDirectory $SourcePath $rollbackCompletedAt = (Get-Date).ToUniversalTime().ToString("o") Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ completedAt = $rollbackCompletedAt restartLaunched = $true restartPid = $null restartError = $null } try { $rollbackRestart = Start-ForgeFlow -WorkingDirectory $SourcePath Write-UpdateLog "Rollback restored and ForgeFlow restarted directly with Electron PID $($rollbackRestart.Id)." Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ completedAt = $rollbackCompletedAt restartLaunched = $true restartPid = $rollbackRestart.Id restartError = $null } } catch { $rollbackRestartError = $_.Exception.Message Write-UpdateLog ("Rollback restart failed: " + $rollbackRestartError) Write-UpdateState -State "rolled-back" -Message $failureMessage -Extra @{ completedAt = $rollbackCompletedAt restartLaunched = $false restartPid = $null restartError = $rollbackRestartError } } } } catch { try { Write-UpdateLog ("Rollback failed: " + $_.Exception.Message) } catch {} try { Write-UpdateState -State "failed" -Message ("$failureMessage Rollback also failed: " + $_.Exception.Message) -Extra @{ completedAt = (Get-Date).ToUniversalTime().ToString("o") } } catch {} } exit 1 }