237 lines
10 KiB
PowerShell
237 lines
10 KiB
PowerShell
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
|
|
}
|