# Posly Print Agent updater. # # Pulls a versioned zip from install.posly.xyz and replaces application files # in place. Preserves paired credentials and migrates the v0.6.25 Node-side # pairing into the agent directory when required. It may repair launcher.bat # and the Startup wrapper so the agent restarts from the updated directory. # # One-liner: # iex (irm install.posly.xyz/update.ps1) # # Use when: # - We ship a print-agent code change (e.g. 2026-06-07 receipt formatting # sweep for 58mm thermal width). # - You see broken receipts or stale behaviour and want to refresh. # # Safe to re-run; idempotent overwrite of dist/. $ErrorActionPreference = "Stop" $script:AutoLogPath = $null $script:PostStopBoundary = $false $script:RollbackComplete = $false $script:RollbackInProgress = $false $script:RollbackAttempted = $false $script:RollbackAgentRestarted = $false $script:PreserveRecoveryFiles = $false $script:TestFault = "" $script:AttemptId = if ($env:POSLY_UPDATE_ATTEMPT_ID) { $env:POSLY_UPDATE_ATTEMPT_ID } else { [Guid]::NewGuid().ToString("N") } $script:LegacyPairingActive = $false $script:LegacyActivePrinterPath = $null $script:PairingMigrationState = "none" $script:PrinterMigrationState = "none" $script:PairingMigrationBackup = $null $script:PrinterMigrationBackup = $null $script:LegacyPairingPath = $null $script:LegacyPrinterPath = $null $script:RecoveryDir = $null $script:ValidatedAgentProcessId = 0 $script:ValidatedAgentCreationDate = "" $script:ValidatedNodePath = $null $script:UpdateLockPath = $null $script:UpdateLockStream = $null $script:UpdateLockContentionExitCode = 75 $script:SupportPathBackups = @() $script:SupportPaths = @("install", "printer-profiles", "printer-config.example.json", ".env.example", "README.md") function Write-AutoLog($level, $msg) { if (-not $script:AutoLogPath) { return } $line = "{0} {1} {2}" -f (Get-Date).ToUniversalTime().ToString("o"), $level, $msg Add-Content -LiteralPath $script:AutoLogPath -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } function Say($msg) { Write-AutoLog "INFO" $msg; Write-Host "[posly]" $msg -ForegroundColor Cyan } function Warn($msg) { Write-AutoLog "WARN" $msg; Write-Host "[posly]" $msg -ForegroundColor Yellow } function Fail($msg) { Write-AutoLog "ERROR" $msg; Write-Host "[posly]" $msg -ForegroundColor Red; throw $msg } function Write-UpdaterHandoffClaim { if ([string]::IsNullOrWhiteSpace($env:POSLY_UPDATE_HANDOFF_PATH)) { return } if ([string]::IsNullOrWhiteSpace($env:POSLY_UPDATE_ATTEMPT_ID)) { throw "updater handoff attempt id is missing" } $claim = [ordered]@{ attemptId = $script:AttemptId pid = $PID claimedAt = (Get-Date).ToUniversalTime().ToString("o") } $json = $claim | ConvertTo-Json -Compress $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($env:POSLY_UPDATE_HANDOFF_PATH, $json, $utf8NoBom) } # Claim process ownership before directory inspection, hashing, or downloads. # The attached launcher will not release the old agent until it sees this # exact attempt id and updater PID in a separate process. Write-UpdaterHandoffClaim function Get-ArchiveSignature { param([string]$Path) $stream = [System.IO.File]::OpenRead($Path) try { $buffer = New-Object byte[] 4 $count = $stream.Read($buffer, 0, $buffer.Length) } finally { $stream.Dispose() } $bytes = if ($count -gt 0) { $buffer[0..($count - 1)] } else { @() } [PSCustomObject]@{ Hex = (($bytes | ForEach-Object { $_.ToString("x2") }) -join " ") IsZip = $count -eq 4 -and $buffer[0] -eq 0x50 -and $buffer[1] -eq 0x4b -and $buffer[2] -eq 0x03 -and $buffer[3] -eq 0x04 } } function Test-AgentInstallDirectory { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $false } $entryPath = Join-Path $Path "dist\index.js" $packagePath = Join-Path $Path "package.json" if (-not (Test-Path -LiteralPath $entryPath) -or -not (Test-Path -LiteralPath $packagePath)) { return $false } try { return ((Get-Content -LiteralPath $packagePath -Raw | ConvertFrom-Json).name -eq "@posly/print-agent") } catch { return $false } } function Get-RunningAgentDirectory { try { $processes = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue foreach ($process in $processes) { $commandLine = [string]$process.CommandLine if ([string]::IsNullOrWhiteSpace($commandLine) -or $commandLine -notlike "*dist\index.js*") { continue } $entryMatch = [regex]::Match($commandLine, '(?i)([A-Z]:\\[^"'']*?\\dist\\index\.js)') if (-not $entryMatch.Success) { continue } $candidate = Split-Path (Split-Path $entryMatch.Groups[1].Value -Parent) -Parent if (Test-AgentInstallDirectory $candidate) { return $candidate } } } catch {} return $null } function Get-StartupAgentDirectory { foreach ($startupDir in @([Environment]::GetFolderPath("Startup"), [Environment]::GetFolderPath("CommonStartup"))) { if ([string]::IsNullOrWhiteSpace($startupDir)) { continue } $vbsPath = Join-Path $startupDir "posly-print-agent.vbs" if (-not (Test-Path -LiteralPath $vbsPath)) { continue } try { $launcher = Get-Content -LiteralPath $vbsPath -Raw $launchMatch = [regex]::Match($launcher, '(?i)([A-Z]:\\[^"'']*?\\launch\.bat)') if (-not $launchMatch.Success) { continue } $candidate = Split-Path $launchMatch.Groups[1].Value -Parent if (Test-AgentInstallDirectory $candidate) { return $candidate } } catch {} } return $null } function Acquire-UpdateLock { param([string]$InstallDir) $script:UpdateLockPath = Join-Path $InstallDir ".auto-update.lock" try { $script:UpdateLockStream = [System.IO.File]::Open( $script:UpdateLockPath, [System.IO.FileMode]::OpenOrCreate, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None ) } catch [System.IO.IOException] { $message = "another updater already owns the install-level lock for $InstallDir" Write-Host "[posly]" $message -ForegroundColor Red exit $script:UpdateLockContentionExitCode } try { $lockBody = [ordered]@{ attemptId = $script:AttemptId pid = $PID acquiredAt = (Get-Date).ToUniversalTime().ToString("o") installDir = [System.IO.Path]::GetFullPath($InstallDir) } | ConvertTo-Json -Compress $lockBytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($lockBody + "`r`n") $script:UpdateLockStream.SetLength(0) $script:UpdateLockStream.Write($lockBytes, 0, $lockBytes.Length) $script:UpdateLockStream.Flush($true) } catch { $script:UpdateLockStream.Dispose() $script:UpdateLockStream = $null throw "could not persist install-level update lock ownership: $($_.Exception.Message)" } } function Release-UpdateLock { if ($script:UpdateLockStream) { try { $script:UpdateLockStream.Dispose() } catch {} $script:UpdateLockStream = $null } } function Get-SupportPathState { param([string]$Path) if (Test-Path -LiteralPath $Path -PathType Leaf) { return "file" } if (Test-Path -LiteralPath $Path -PathType Container) { return "directory" } if (Test-Path -LiteralPath $Path) { throw "support path is neither a regular file nor directory: $Path" } return "absent" } function Get-SupportPathSignature { param( [string]$Path, [string]$State ) if ($State -eq "absent") { return "absent" } $actualState = Get-SupportPathState $Path if ($actualState -ne $State) { throw "support path $Path is $actualState instead of $State" } if ($State -eq "file") { $file = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (($file.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "support path cannot be a reparse point: $Path" } $hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() return "file|$($file.Length)|$hash" } $root = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if (($root.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "support directory cannot be a reparse point: $Path" } $rootFullPath = [System.IO.Path]::GetFullPath($Path).TrimEnd([char[]]@('\', '/')) $rootPrefix = $rootFullPath + [System.IO.Path]::DirectorySeparatorChar $entries = @("directory|.") foreach ($item in @(Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction Stop | Sort-Object FullName)) { if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw "support directory contains a reparse point: $($item.FullName)" } $itemFullPath = [System.IO.Path]::GetFullPath($item.FullName) if (-not $itemFullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "support directory item escaped its root: $itemFullPath" } $relativePath = $itemFullPath.Substring($rootPrefix.Length).Replace('\', '/') if ($item.PSIsContainer) { $entries += "directory|$relativePath" } else { $hash = (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash.ToLowerInvariant() $entries += "file|$relativePath|$($item.Length)|$hash" } } return (($entries | Sort-Object) -join "`n") } function Backup-SupportPaths { param( [string]$InstallDir, [string]$BackupDir, [string[]]$RelativePaths ) New-Item -ItemType Directory -Path $BackupDir -Force -ErrorAction Stop | Out-Null $backups = @() for ($index = 0; $index -lt $RelativePaths.Count; $index += 1) { $relativePath = $RelativePaths[$index] $destination = Join-Path $InstallDir $relativePath $state = Get-SupportPathState $destination $signature = Get-SupportPathSignature $destination $state $backupPath = Join-Path $BackupDir ("support-path-" + $index) if (Test-Path -LiteralPath $backupPath) { Remove-Item -LiteralPath $backupPath -Recurse -Force -ErrorAction Stop } if ($state -eq "file") { Copy-Item -LiteralPath $destination -Destination $backupPath -Force -ErrorAction Stop } elseif ($state -eq "directory") { Copy-Item -LiteralPath $destination -Destination $backupPath -Recurse -Force -ErrorAction Stop } if ($state -ne "absent") { $backupSignature = Get-SupportPathSignature $backupPath $state if ($backupSignature -cne $signature) { throw "support path backup verification failed for $relativePath" } } $backups += [PSCustomObject]@{ RelativePath = $relativePath State = $state Signature = $signature BackupPath = $backupPath } } $script:SupportPathBackups = $backups } function Restore-SupportPaths { param([string]$InstallDir) foreach ($backup in @($script:SupportPathBackups)) { $destination = Join-Path $InstallDir ([string]$backup.RelativePath) if ((Get-SupportPathState $destination) -ne "absent") { Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop } if ([string]$backup.State -eq "file") { Copy-Item -LiteralPath $backup.BackupPath -Destination $destination -Force -ErrorAction Stop } elseif ([string]$backup.State -eq "directory") { Copy-Item -LiteralPath $backup.BackupPath -Destination $destination -Recurse -Force -ErrorAction Stop } $restoredState = Get-SupportPathState $destination if ($restoredState -ne [string]$backup.State) { throw "support path rollback restored $($backup.RelativePath) as $restoredState instead of $($backup.State)" } $restoredSignature = Get-SupportPathSignature $destination $restoredState if ($restoredSignature -cne [string]$backup.Signature) { throw "support path rollback did not restore exact bytes for $($backup.RelativePath)" } } $script:SupportPathBackups = @() } function Stop-OtherRunningAgents { param( [string]$InstallDir, [int]$ExceptProcessId ) $deadline = (Get-Date).AddSeconds(15) while ((Get-Date) -lt $deadline) { $duplicates = @( Get-ExactAgentProcesses $InstallDir | Where-Object { [int]$_.ProcessId -ne $ExceptProcessId } ) if ($duplicates.Count -eq 0) { return } foreach ($process in $duplicates) { Say (" killing duplicate agent pid " + $process.ProcessId + " for exact install " + $InstallDir) Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 250 } $remaining = @( Get-ExactAgentProcesses $InstallDir | Where-Object { [int]$_.ProcessId -ne $ExceptProcessId } ) if ($remaining.Count -gt 0) { $remainingIds = @($remaining | ForEach-Object { [string]$_.ProcessId }) -join "," throw "same-install print-agent processes survived stop verification for ${InstallDir}: $remainingIds" } } function Get-ValidatedAgentProcess { param( [string]$InstallDir, [int]$ProcessId, [string]$ExpectedNodePath = "" ) if ($ProcessId -le 0) { throw "automatic updater received an invalid source agent pid" } $process = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop if (-not $process -or [string]$process.Name -ne "node.exe") { throw "automatic updater could not validate source agent pid $ProcessId" } $nodePath = [string]$process.ExecutablePath if ([string]::IsNullOrWhiteSpace($nodePath) -or -not (Test-Path -LiteralPath $nodePath -PathType Leaf)) { throw "automatic updater could not resolve node.exe for source agent pid $ProcessId" } $nodePath = [System.IO.Path]::GetFullPath($nodePath) if (-not [string]::IsNullOrWhiteSpace($ExpectedNodePath)) { $expectedNode = [System.IO.Path]::GetFullPath($ExpectedNodePath) if (-not $nodePath.Equals($expectedNode, [System.StringComparison]::OrdinalIgnoreCase)) { throw "automatic updater rejected a changed Node executable for source agent pid $ProcessId" } } $expectedEntry = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "dist\index.js")) $commandLine = [string]$process.CommandLine if ([string]::IsNullOrWhiteSpace($commandLine) -or $commandLine.IndexOf($expectedEntry, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { throw "automatic updater rejected a source pid that does not run the selected print-agent install" } $creationDate = [string]$process.CreationDate if ($script:ValidatedAgentProcessId -gt 0) { if ($script:ValidatedAgentProcessId -ne $ProcessId -or -not $nodePath.Equals($script:ValidatedNodePath, [System.StringComparison]::OrdinalIgnoreCase) -or $creationDate -ne $script:ValidatedAgentCreationDate) { throw "automatic updater rejected a replaced or reused source agent pid $ProcessId" } } else { $script:ValidatedAgentProcessId = $ProcessId $script:ValidatedAgentCreationDate = $creationDate $script:ValidatedNodePath = $nodePath } return $process } function Get-NodeExecutable { param( [string]$InstallDir, [int]$AgentProcessId = 0, [bool]$RequireExactProcess = $false ) if ($RequireExactProcess) { $expectedNodePath = if ($env:POSLY_NODE_BIN) { $env:POSLY_NODE_BIN } else { "" } $sourceProcess = Get-ValidatedAgentProcess $InstallDir $AgentProcessId $expectedNodePath return [System.IO.Path]::GetFullPath([string]$sourceProcess.ExecutablePath) } if ($env:POSLY_NODE_BIN -and (Test-Path -LiteralPath $env:POSLY_NODE_BIN -PathType Leaf)) { return [System.IO.Path]::GetFullPath($env:POSLY_NODE_BIN) } $expectedEntry = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "dist\index.js")) try { $processes = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction Stop foreach ($process in $processes) { $commandLine = [string]$process.CommandLine $executablePath = [string]$process.ExecutablePath if ([string]::IsNullOrWhiteSpace($commandLine) -or $commandLine.IndexOf($expectedEntry, [System.StringComparison]::OrdinalIgnoreCase) -lt 0 -or [string]::IsNullOrWhiteSpace($executablePath) -or -not (Test-Path -LiteralPath $executablePath -PathType Leaf)) { continue } return [System.IO.Path]::GetFullPath($executablePath) } } catch {} $existingLauncher = Join-Path $InstallDir "launch.bat" if (Test-Path -LiteralPath $existingLauncher -PathType Leaf) { try { $launcherBody = Get-Content -LiteralPath $existingLauncher -Raw $nodeMatches = [regex]::Matches($launcherBody, '(?i)"(?[A-Z]:\\[^"\r\n]*?\\node\.exe)"') foreach ($nodeMatch in $nodeMatches) { $candidate = $nodeMatch.Groups["node"].Value if (Test-Path -LiteralPath $candidate -PathType Leaf) { return [System.IO.Path]::GetFullPath($candidate) } } } catch {} } $portableNode = Join-Path $InstallDir "node\node.exe" if (Test-Path -LiteralPath $portableNode) { return $portableNode } $nodeCmd = Get-Command node.exe -ErrorAction SilentlyContinue if ($nodeCmd -and (Test-Path -LiteralPath $nodeCmd.Source)) { return $nodeCmd.Source } $nodeCmd = Get-Command node -ErrorAction SilentlyContinue if ($nodeCmd -and (Test-Path -LiteralPath $nodeCmd.Source)) { return $nodeCmd.Source } return $null } function Test-PairingFile { param([string]$Path) try { $raw = [System.IO.File]::ReadAllText($Path).TrimStart([char]0xFEFF) $config = $raw | ConvertFrom-Json $uri = $null return -not [string]::IsNullOrWhiteSpace([string]$config.tenantId) -and -not [string]::IsNullOrWhiteSpace([string]$config.agentToken) -and [Uri]::TryCreate([string]$config.apiUrl, [UriKind]::Absolute, [ref]$uri) -and @("http", "https") -contains $uri.Scheme } catch { return $false } } function Get-PrinterConfigSummary { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "printer configuration is missing at $Path" } try { $raw = [System.IO.File]::ReadAllText($Path).TrimStart([char]0xFEFF) $parsed = $raw | ConvertFrom-Json } catch { throw "printer configuration is not valid JSON at ${Path}: $($_.Exception.Message)" } $entries = @() $trimmedRaw = $raw.Trim() if ($trimmedRaw -eq "[]") { $entries = @() } elseif ($null -ne $parsed -and $null -ne $parsed.PSObject.Properties["stations"]) { $entries = @($parsed.stations) } elseif ($trimmedRaw.StartsWith("[")) { $entries = @($parsed) } else { throw "printer configuration must contain a stations array or a top-level array at $Path" } $stationIds = @() foreach ($entry in $entries) { if ($null -eq $entry -or [string]::IsNullOrWhiteSpace([string]$entry.stationId)) { throw "printer configuration contains a station without stationId at $Path" } $stationIds += [string]$entry.stationId } if (@($stationIds | Select-Object -Unique).Count -ne $stationIds.Count) { throw "printer configuration contains duplicate stationId values at $Path" } return [PSCustomObject]@{ Count = $stationIds.Count StationIds = $stationIds Sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash } } function Initialize-LegacyPairingMigration { param( [string]$InstallDir, [string]$NodePath ) $nodeDir = Split-Path -Parent $NodePath $script:LegacyPairingPath = Join-Path $nodeDir "posly-agent.json" $script:LegacyPrinterPath = Join-Path $nodeDir "printer-config.json" $currentConfigModule = Join-Path $InstallDir "dist\config.js" $usesLegacyNodePairing = $false if (Test-Path -LiteralPath $currentConfigModule -PathType Leaf) { try { $usesLegacyNodePairing = [bool](Select-String -LiteralPath $currentConfigModule -Pattern "dirname(process.execPath)" -SimpleMatch -Quiet) } catch {} } if ($usesLegacyNodePairing -and (Test-Path -LiteralPath $script:LegacyPairingPath -PathType Leaf)) { if (-not (Test-PairingFile $script:LegacyPairingPath)) { throw "the active legacy Windows pairing file is invalid" } $script:LegacyPairingActive = $true $legacyPairing = [System.IO.File]::ReadAllText($script:LegacyPairingPath).TrimStart([char]0xFEFF) | ConvertFrom-Json $configuredPrinterPath = [string]$legacyPairing.printerConfigPath if ([string]::IsNullOrWhiteSpace($configuredPrinterPath)) { $script:LegacyActivePrinterPath = $script:LegacyPrinterPath } elseif ([System.IO.Path]::IsPathRooted($configuredPrinterPath)) { $script:LegacyActivePrinterPath = [System.IO.Path]::GetFullPath($configuredPrinterPath) } else { $script:LegacyActivePrinterPath = [System.IO.Path]::GetFullPath((Join-Path $InstallDir $configuredPrinterPath)) } if (-not (Test-Path -LiteralPath $script:LegacyActivePrinterPath -PathType Leaf)) { throw "the active legacy Windows printer configuration is missing" } $legacyPrinterSummary = Get-PrinterConfigSummary $script:LegacyActivePrinterPath $agentPairingPath = Join-Path $InstallDir "posly-agent.json" if (Test-Path -LiteralPath $agentPairingPath -PathType Leaf) { if (-not (Test-PairingFile $agentPairingPath)) { throw "the existing agent-directory pairing file is invalid" } $managedPairing = [System.IO.File]::ReadAllText($agentPairingPath).TrimStart([char]0xFEFF) | ConvertFrom-Json foreach ($identityField in @("apiUrl", "tenantId", "agentToken")) { if ([string]$managedPairing.$identityField -ne [string]$legacyPairing.$identityField) { throw "the existing agent-directory pairing conflicts with the active legacy venue identity" } } } $agentPrinterPath = Join-Path $InstallDir "printer-config.json" if (Test-Path -LiteralPath $agentPrinterPath -PathType Leaf) { $managedPrinterSummary = Get-PrinterConfigSummary $agentPrinterPath if ($managedPrinterSummary.Count -gt 0 -and $managedPrinterSummary.Sha256 -ne $legacyPrinterSummary.Sha256) { throw "the existing agent-directory printer config conflicts with the active legacy printer config" } } Say ("validated active legacy Windows printer config with " + $legacyPrinterSummary.Count + " station(s)") } $agentPairingPath = Join-Path $InstallDir "posly-agent.json" $agentEnvPath = Join-Path $InstallDir ".env" if (-not (Test-Path -LiteralPath $agentPairingPath -PathType Leaf) -and -not (Test-Path -LiteralPath $agentEnvPath -PathType Leaf) -and -not $script:LegacyPairingActive) { throw "the agent has no saved venue pairing" } } function Invoke-LegacyPairingMigration { param( [string]$InstallDir, [string]$BackupDir ) if (-not $script:LegacyPairingActive) { return } New-Item -ItemType Directory -Path $BackupDir -Force -ErrorAction Stop | Out-Null $agentPairingPath = Join-Path $InstallDir "posly-agent.json" $pairingNext = Join-Path $InstallDir (".posly-agent.json.update." + $PID) $script:PairingMigrationBackup = Join-Path $BackupDir "posly-agent.json.before-migration" if (Test-Path -LiteralPath $agentPairingPath -PathType Leaf) { Copy-Item -LiteralPath $agentPairingPath -Destination $script:PairingMigrationBackup -Force -ErrorAction Stop $script:PairingMigrationState = "replaced" } $migratedPairing = [System.IO.File]::ReadAllText($script:LegacyPairingPath).TrimStart([char]0xFEFF) | ConvertFrom-Json $migratedPairing.PSObject.Properties.Remove("printerConfigPath") $migratedPairingJson = $migratedPairing | ConvertTo-Json -Depth 100 $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($pairingNext, $migratedPairingJson + "`r`n", $utf8NoBom) if (-not (Test-PairingFile $pairingNext)) { throw "the copied legacy Windows pairing file failed validation" } Move-Item -LiteralPath $pairingNext -Destination $agentPairingPath -Force -ErrorAction Stop if ($script:PairingMigrationState -eq "none") { $script:PairingMigrationState = "created" } $agentPrinterPath = Join-Path $InstallDir "printer-config.json" $printerNext = Join-Path $InstallDir (".printer-config.json.update." + $PID) $script:PrinterMigrationBackup = Join-Path $BackupDir "printer-config.json.before-migration" if (Test-Path -LiteralPath $agentPrinterPath -PathType Leaf) { Copy-Item -LiteralPath $agentPrinterPath -Destination $script:PrinterMigrationBackup -Force -ErrorAction Stop $script:PrinterMigrationState = "replaced" } Copy-Item -LiteralPath $script:LegacyActivePrinterPath -Destination $printerNext -Force -ErrorAction Stop Move-Item -LiteralPath $printerNext -Destination $agentPrinterPath -Force -ErrorAction Stop if ($script:PrinterMigrationState -eq "none") { $script:PrinterMigrationState = "created" } Say "migrated the active legacy Windows pairing and printer config into the agent directory" } function Restore-LegacyPairingMigration { param([string]$InstallDir) $agentPairingPath = Join-Path $InstallDir "posly-agent.json" $agentPrinterPath = Join-Path $InstallDir "printer-config.json" $pairingNext = Join-Path $InstallDir (".posly-agent.json.restore." + $PID) $printerNext = Join-Path $InstallDir (".printer-config.json.restore." + $PID) Remove-Item -LiteralPath (Join-Path $InstallDir (".posly-agent.json.update." + $PID)) -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $InstallDir (".printer-config.json.update." + $PID)) -Force -ErrorAction SilentlyContinue if ($script:TestFault -eq "restore-failure" -and ($script:PairingMigrationState -ne "none" -or $script:PrinterMigrationState -ne "none")) { $script:PreserveRecoveryFiles = $true throw "injected destination config restore failure" } switch ($script:PairingMigrationState) { "replaced" { Copy-Item -LiteralPath $script:PairingMigrationBackup -Destination $pairingNext -Force -ErrorAction Stop Move-Item -LiteralPath $pairingNext -Destination $agentPairingPath -Force -ErrorAction Stop } "created" { Remove-Item -LiteralPath $agentPairingPath -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $agentPairingPath) { throw "could not remove the migrated pairing file" } } } switch ($script:PrinterMigrationState) { "replaced" { Copy-Item -LiteralPath $script:PrinterMigrationBackup -Destination $printerNext -Force -ErrorAction Stop Move-Item -LiteralPath $printerNext -Destination $agentPrinterPath -Force -ErrorAction Stop } "created" { Remove-Item -LiteralPath $agentPrinterPath -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $agentPrinterPath) { throw "could not remove the migrated printer config" } } } $script:PairingMigrationState = "none" $script:PrinterMigrationState = "none" } function Write-UpdateState { param([string]$Phase, [string]$ErrorMessage = "", [switch]$BestEffort) if (-not $script:UpdateStatePath) { if ($BestEffort) { return } throw "update state path is not configured" } try { if ($script:TestFault -eq "state-write-failure" -and $Phase -eq "staged") { throw "injected state write failure" } $startedAt = (Get-Date).ToUniversalTime().ToString("o") if (Test-Path -LiteralPath $script:UpdateStatePath) { try { $existing = Get-Content -LiteralPath $script:UpdateStatePath -Raw | ConvertFrom-Json if ($existing.attemptId -eq $script:AttemptId -and $existing.startedAt) { $startedAt = [string]$existing.startedAt } } catch {} } $state = [ordered]@{ attemptId = $script:AttemptId fromVersion = $script:CurrentVersion toVersion = $agentVersion phase = $Phase startedAt = $startedAt updatedAt = (Get-Date).ToUniversalTime().ToString("o") installDir = $agentDir pid = $PID } if (-not [string]::IsNullOrWhiteSpace($ErrorMessage)) { $state.error = $ErrorMessage } $json = $state | ConvertTo-Json -Depth 4 $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($script:UpdateStatePath, $json, $utf8NoBom) } catch { $stateError = "could not persist update state phase ${Phase}: " + $_.Exception.Message Write-AutoLog "WARN" $stateError if (-not $BestEffort) { throw $stateError } } } function Assert-AgentFilesVersion { param( [string]$DistPath, [string]$PackagePath, [string]$ExpectedVersion, [string]$Label, [string]$ExpectedEntrySha256 = "", [string]$ExpectedPackageSha256 = "" ) $entryPath = Join-Path $DistPath "index.js" if (-not (Test-Path -LiteralPath $entryPath -PathType Leaf)) { throw "$Label is missing dist\index.js" } if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { throw "$Label is missing package.json" } $package = Get-Content -LiteralPath $PackagePath -Raw | ConvertFrom-Json if ($package.name -ne "@posly/print-agent") { throw "$Label has unexpected package name $($package.name)" } if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and $package.version -ne $ExpectedVersion) { throw "$Label has version $($package.version), expected $ExpectedVersion" } if (-not [string]::IsNullOrWhiteSpace($ExpectedEntrySha256)) { $entrySha256 = (Get-FileHash -LiteralPath $entryPath -Algorithm SHA256).Hash if ($entrySha256 -ne $ExpectedEntrySha256) { throw "$Label has unexpected dist\index.js bytes" } } if (-not [string]::IsNullOrWhiteSpace($ExpectedPackageSha256)) { $packageSha256 = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash if ($packageSha256 -ne $ExpectedPackageSha256) { throw "$Label has unexpected package.json bytes" } } } function Get-ExactAgentProcesses { param([string]$InstallDir) $expectedEntry = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "dist\index.js")) $processes = Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction Stop foreach ($process in $processes) { $commandLine = [string]$process.CommandLine if ([string]::IsNullOrWhiteSpace($commandLine)) { continue } if ($commandLine.IndexOf($expectedEntry, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { Write-Output $process } } } function Stop-ExactAgentProcesses { param([string]$InstallDir) $deadline = (Get-Date).AddSeconds(15) while ((Get-Date) -lt $deadline) { $processes = @(Get-ExactAgentProcesses $InstallDir) if ($processes.Count -eq 0) { return } foreach ($process in $processes) { Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue } Start-Sleep -Milliseconds 250 } $remaining = @(Get-ExactAgentProcesses $InstallDir) if ($remaining.Count -gt 0) { throw "could not stop exact print agent processes for $InstallDir" } } function Wait-OneExactAgentProcess { param( [string]$InstallDir, [int]$TimeoutSeconds = 30 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $stableProcessId = 0 $stableChecks = 0 while ((Get-Date) -lt $deadline) { $processes = @(Get-ExactAgentProcesses $InstallDir) if ($processes.Count -gt 1) { throw "restart created $($processes.Count) exact print-agent processes for $InstallDir" } if ($processes.Count -eq 1) { $processId = [int]$processes[0].ProcessId if ($processId -eq $stableProcessId) { $stableChecks += 1 } else { $stableProcessId = $processId $stableChecks = 1 } if ($stableChecks -ge 3) { return $stableProcessId } } else { $stableProcessId = 0 $stableChecks = 0 } Start-Sleep -Milliseconds 500 } throw "exact print agent did not remain running for $InstallDir" } function Write-ExactLauncher { param( [string]$InstallDir, [string]$NodePath, [string]$LauncherPath, [string]$AgentLogPath, [bool]$AutoUpdateEnabled = $true ) if (-not (Test-Path -LiteralPath $NodePath -PathType Leaf)) { throw "resolved node.exe does not exist at $NodePath" } $entryPath = Join-Path $InstallDir "dist\index.js" $autoUpdateValue = if ($AutoUpdateEnabled) { "1" } else { "0" } $launcher = @" @echo off cd /d "$InstallDir" set "POSLY_AUTO_UPDATE=$autoUpdateValue" echo [%date% %time%] launcher fired >> "$AgentLogPath" "$NodePath" "$entryPath" >> "$AgentLogPath" 2>&1 echo [%date% %time%] node exited rc=%errorlevel% >> "$AgentLogPath" "@ [System.IO.File]::WriteAllText($LauncherPath, $launcher, [System.Text.Encoding]::ASCII) $writtenLauncher = [System.IO.File]::ReadAllText($LauncherPath) if ($writtenLauncher.IndexOf($NodePath, [System.StringComparison]::OrdinalIgnoreCase) -lt 0 -or $writtenLauncher.IndexOf($entryPath, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { throw "launcher validation failed at $LauncherPath" } } function Write-ExactStartupWrapper { param( [string]$LauncherPath, [string]$WrapperPath ) $startupWrapper = @" ' Posly Print Agent autostart wrapper. Set objShell = CreateObject("WScript.Shell") objShell.Run Chr(34) & "$LauncherPath" & Chr(34), 0, False "@ [System.IO.File]::WriteAllText($WrapperPath, $startupWrapper, [System.Text.Encoding]::ASCII) $writtenWrapper = [System.IO.File]::ReadAllText($WrapperPath) if ($writtenWrapper.IndexOf($LauncherPath, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { throw "login launcher validation failed at $WrapperPath" } } function Start-ExactAgentAndProve { param( [string]$InstallDir, [string]$LauncherPath, [string]$ExpectedVersion, [string]$ExpectedEntrySha256 = "", [string]$ExpectedPackageSha256 = "" ) Assert-AgentFilesVersion (Join-Path $InstallDir "dist") (Join-Path $InstallDir "package.json") $ExpectedVersion "installed print agent" $ExpectedEntrySha256 $ExpectedPackageSha256 Stop-ExactAgentProcesses $InstallDir Start-Process -FilePath "cmd.exe" -ArgumentList "/c", "`"$LauncherPath`"" -WindowStyle Hidden -ErrorAction Stop | Out-Null $processId = Wait-OneExactAgentProcess $InstallDir 30 Assert-AgentFilesVersion (Join-Path $InstallDir "dist") (Join-Path $InstallDir "package.json") $ExpectedVersion "running print agent" $ExpectedEntrySha256 $ExpectedPackageSha256 return $processId } function Assert-UpdateStatePhase { param([string]$ExpectedPhase) if (-not (Test-Path -LiteralPath $script:UpdateStatePath -PathType Leaf)) { throw "update state file is missing" } $state = Get-Content -LiteralPath $script:UpdateStatePath -Raw | ConvertFrom-Json if ($state.attemptId -ne $script:AttemptId -or $state.phase -ne $ExpectedPhase) { throw "update state did not persist phase $ExpectedPhase" } } function Invoke-RollbackAndRestart { param([string]$Reason) if ($script:RollbackComplete) { return } if ($script:RollbackInProgress) { throw "rollback was already in progress" } if ($script:RollbackAttempted) { throw "rollback recovery was already attempted" } $script:RollbackAttempted = $true $script:RollbackInProgress = $true try { Write-UpdateState "rollback_started" $Reason -BestEffort Assert-AgentFilesVersion $previousDist $previousPackage $script:CurrentVersion "rollback backup" $script:CurrentEntrySha256 $script:CurrentPackageSha256 Stop-OtherRunningAgents -ExceptProcessId 0 -InstallDir $agentDir Stop-ExactAgentProcesses $agentDir $rollbackDist = Join-Path $agentDir "dist.rollback" $rollbackPackage = Join-Path $agentDir "package.json.rollback" if (Test-Path -LiteralPath $rollbackDist) { Remove-Item $rollbackDist -Recurse -Force -ErrorAction Stop } if (Test-Path -LiteralPath $rollbackPackage) { Remove-Item $rollbackPackage -Force -ErrorAction Stop } Copy-Item -Path $previousDist -Destination $rollbackDist -Recurse -Force -ErrorAction Stop Copy-Item -Path $previousPackage -Destination $rollbackPackage -Force -ErrorAction Stop Assert-AgentFilesVersion $rollbackDist $rollbackPackage $script:CurrentVersion "prepared rollback" $script:CurrentEntrySha256 $script:CurrentPackageSha256 if (Test-Path -LiteralPath $oldDist) { Remove-Item $oldDist -Recurse -Force -ErrorAction Stop } if (Test-Path -LiteralPath $oldPackage) { Remove-Item $oldPackage -Force -ErrorAction Stop } Move-Item -Path $rollbackDist -Destination $oldDist -Force -ErrorAction Stop Move-Item -Path $rollbackPackage -Destination $oldPackage -Force -ErrorAction Stop Assert-AgentFilesVersion $oldDist $oldPackage $script:CurrentVersion "restored print agent" $script:CurrentEntrySha256 $script:CurrentPackageSha256 $restorationErrors = @() try { Restore-LegacyPairingMigration $agentDir } catch { $restorationErrors += ("destination config restoration failed: " + $_.Exception.Message) $script:PreserveRecoveryFiles = $true Write-AutoLog "ERROR" ("destination config restoration failed; recovery files retained: " + $_.Exception.Message) } try { Restore-SupportPaths $agentDir } catch { $restorationErrors += ("support path restoration failed: " + $_.Exception.Message) $script:PreserveRecoveryFiles = $true Write-AutoLog "ERROR" ("support path restoration failed; recovery files retained: " + $_.Exception.Message) } # A .25 agent reads pairing and printers beside its Node executable. Its # untouched legacy files remain authoritative even if destination cleanup # failed, so restart it before reporting the cleanup failure. Write-ExactLauncher $agentDir $nodeExe $batPath $logPath $false [void](Start-ExactAgentAndProve $agentDir $batPath $script:CurrentVersion $script:CurrentEntrySha256 $script:CurrentPackageSha256) $script:RollbackAgentRestarted = $true if ($restorationErrors.Count -gt 0) { $recoveryFailure = $Reason + "; previous agent restarted but recovery was incomplete: " + ($restorationErrors -join "; ") + "; previous version restarted locally; production heartbeat was not verified by updater" Write-UpdateState "failed" $recoveryFailure -BestEffort throw $recoveryFailure } $restartedFailure = $Reason + "; previous version restarted locally; production heartbeat was not verified by updater" Write-UpdateState "failed" $restartedFailure Assert-UpdateStatePhase "failed" $script:RollbackComplete = $true Remove-Item -LiteralPath $previousDist -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $previousPackage -Force -ErrorAction SilentlyContinue if ($script:RecoveryDir) { Remove-Item -LiteralPath $script:RecoveryDir -Recurse -Force -ErrorAction SilentlyContinue } Say ("rollback restored and restarted version " + $script:CurrentVersion + " locally; production heartbeat remains unverified") } finally { $script:RollbackInProgress = $false } } function Restore-PreviousFiles { param([string]$Reason) Invoke-RollbackAndRestart $Reason } function Wait-HeartbeatConfirmed { param([int]$TimeoutSeconds = 120) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) while ((Get-Date) -lt $deadline) { try { if (Test-Path -LiteralPath $script:UpdateStatePath) { $state = Get-Content -LiteralPath $script:UpdateStatePath -Raw | ConvertFrom-Json if ($state.phase -eq "heartbeat_confirmed") { return $true } if ($state.phase -eq "failed") { return $false } } } catch {} Start-Sleep -Seconds 2 } return $false } $defaultAgentDir = Join-Path $env:USERPROFILE "posly-print-agent" $requestedAgentDir = if ($env:POSLY_AGENT_DIR) { $env:POSLY_AGENT_DIR } else { $null } $runningAgentDir = $null $startupAgentDir = $null $agentDir = $defaultAgentDir $agentDirSource = "default" if (Test-AgentInstallDirectory $requestedAgentDir) { $agentDir = $requestedAgentDir $agentDirSource = "POSLY_AGENT_DIR" } else { $runningAgentDir = Get-RunningAgentDirectory if (Test-AgentInstallDirectory $runningAgentDir) { $agentDir = $runningAgentDir $agentDirSource = "running process" } else { $startupAgentDir = Get-StartupAgentDirectory if (Test-AgentInstallDirectory $startupAgentDir) { $agentDir = $startupAgentDir $agentDirSource = "Windows Startup launcher" } } } $entry = Join-Path $agentDir "dist\index.js" $currentPackage = Join-Path $agentDir "package.json" $autoMode = $env:POSLY_AUTO_UPDATE -eq "1" $agentVersion = if ($env:POSLY_AGENT_VERSION) { ($env:POSLY_AGENT_VERSION).TrimStart("v") } else { "0.6.45" } $targetAgentProcessId = 0 $hasExplicitAgentProcessId = $false if ($env:POSLY_AGENT_PID) { $hasExplicitAgentProcessId = [int]::TryParse($env:POSLY_AGENT_PID, [ref]$targetAgentProcessId) -and $targetAgentProcessId -gt 0 } $updaterPid = $PID $installBase = if ($env:POSLY_INSTALL_BASE) { $env:POSLY_INSTALL_BASE.TrimEnd("/") } else { "https://install.posly.xyz" } $testFaults = @("state-write-failure", "activation-failure", "restart-failure", "heartbeat-timeout", "restore-failure") if ($env:POSLY_UPDATE_TEST_FAULT) { if ($env:POSLY_UPDATE_TEST_MODE -ne "1" -or $installBase -notmatch '^http://(?:127\.0\.0\.1|localhost)(?::\d+)?$') { Fail "update fault injection is restricted to the local Windows verifier" } if ($testFaults -notcontains $env:POSLY_UPDATE_TEST_FAULT) { Fail "unknown update test fault $($env:POSLY_UPDATE_TEST_FAULT)" } $script:TestFault = $env:POSLY_UPDATE_TEST_FAULT } $testLockHoldSeconds = 0 if ($env:POSLY_UPDATE_TEST_LOCK_HOLD_SECONDS) { if ($env:POSLY_UPDATE_TEST_MODE -ne "1" -or $installBase -notmatch '^http://(?:127\.0\.0\.1|localhost)(?::\d+)?$') { Fail "update lock delay is restricted to the local Windows verifier" } if (-not [int]::TryParse($env:POSLY_UPDATE_TEST_LOCK_HOLD_SECONDS, [ref]$testLockHoldSeconds) -or $testLockHoldSeconds -lt 1 -or $testLockHoldSeconds -gt 30) { Fail "the local updater lock test requires 1 to 30 seconds" } } if (-not (Test-AgentInstallDirectory $agentDir)) { Fail "no agent install at $agentDir. Run install.posly.xyz/agent.ps1 first to pair." } Acquire-UpdateLock $agentDir Write-Host ("[posly] install-level update lock acquired for " + $agentDir) -ForegroundColor Cyan if ($testLockHoldSeconds -gt 0) { Start-Sleep -Seconds $testLockHoldSeconds } $archiveName = if ($agentVersion -eq "latest") { "posly-print-agent.zip" } else { "posly-print-agent-$agentVersion.zip" } $escapedVersion = [Uri]::EscapeDataString($agentVersion) $escapedAttemptId = [Uri]::EscapeDataString($script:AttemptId) $archiveUrl = "${installBase}/${archiveName}?posly-version=${escapedVersion}&posly-attempt=${escapedAttemptId}" $manifestUrl = "${installBase}/print-agent-archives.sha256?posly-version=${escapedVersion}&posly-attempt=${escapedAttemptId}" $script:AutoLogPath = if ($autoMode) { Join-Path $agentDir "auto-update.log" } else { $null } $script:UpdateStatePath = Join-Path $agentDir "auto-update-state.json" $script:CurrentVersion = "unknown" $heartbeatTimeoutSeconds = 120 if ($script:TestFault -eq "heartbeat-timeout") { $testHeartbeatTimeout = 0 if (-not [int]::TryParse($env:POSLY_UPDATE_TEST_HEARTBEAT_TIMEOUT_SECONDS, [ref]$testHeartbeatTimeout) -or $testHeartbeatTimeout -lt 1 -or $testHeartbeatTimeout -gt 30) { Fail "the local heartbeat timeout test requires 1 to 30 seconds" } $heartbeatTimeoutSeconds = $testHeartbeatTimeout } trap { $failureMessage = $_.Exception.Message Write-AutoLog "ERROR" ("updater stopped: " + $failureMessage) if ($script:PostStopBoundary -and -not $script:RollbackComplete) { if (-not $script:RollbackAttempted) { try { Invoke-RollbackAndRestart $failureMessage } catch { $rollbackError = $_.Exception.Message $combinedFailure = $failureMessage + "; rollback failed: " + $rollbackError Write-AutoLog "ERROR" $combinedFailure Write-UpdateState "failed" $combinedFailure -BestEffort } } else { $script:PreserveRecoveryFiles = $true Write-UpdateState "failed" $failureMessage -BestEffort } if (-not $script:RollbackAgentRestarted) { $script:PreserveRecoveryFiles = $true try { try { Restore-LegacyPairingMigration $agentDir } catch { Write-AutoLog "ERROR" ("emergency destination config restoration failed: " + $_.Exception.Message) } try { Restore-SupportPaths $agentDir } catch { Write-AutoLog "ERROR" ("emergency support path restoration failed: " + $_.Exception.Message) } Write-ExactLauncher $agentDir $nodeExe $batPath $logPath $false [void](Start-ExactAgentAndProve $agentDir $batPath $script:CurrentVersion $script:CurrentEntrySha256 $script:CurrentPackageSha256) $script:RollbackAgentRestarted = $true $restartFailure = $failureMessage + "; previous version restarted locally; production heartbeat was not verified by updater" Write-UpdateState "failed" $restartFailure -BestEffort Write-AutoLog "WARN" ("emergency restart recovered source version " + $script:CurrentVersion + " locally; production heartbeat remains unverified") } catch { $emergencyFailure = $failureMessage + "; emergency source restart failed: " + $_.Exception.Message Write-AutoLog "ERROR" $emergencyFailure Write-UpdateState "failed" $emergencyFailure -BestEffort } } } elseif (-not $script:RollbackComplete) { try { Restore-LegacyPairingMigration $agentDir } catch { $script:PreserveRecoveryFiles = $true $failureMessage = $failureMessage + "; could not restore the pre-update pairing files: " + $_.Exception.Message Write-AutoLog "ERROR" $failureMessage } Write-UpdateState "failed" $failureMessage -BestEffort } if (-not $script:PostStopBoundary -and -not $script:PreserveRecoveryFiles) { foreach ($stagedPath in @($nextDist, $previousDist, $nextPackage, $previousPackage)) { if ($stagedPath) { if (Test-Path -LiteralPath $stagedPath -PathType Container) { Remove-Item -LiteralPath $stagedPath -Recurse -Force -ErrorAction SilentlyContinue } else { Remove-Item -LiteralPath $stagedPath -Force -ErrorAction SilentlyContinue } } } } if ($tmpZip) { Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue } if ($tmpManifest) { Remove-Item $tmpManifest -Force -ErrorAction SilentlyContinue } if ($tmpExtract) { Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue } if ($script:RecoveryDir -and -not $script:PreserveRecoveryFiles) { Remove-Item -LiteralPath $script:RecoveryDir -Recurse -Force -ErrorAction SilentlyContinue } Release-UpdateLock exit 1 } Say ("updater started; version=" + $agentVersion + "; updaterPid=" + $updaterPid + "; agentPid=" + $targetAgentProcessId + "; requestedAgentDir=" + $requestedAgentDir + "; agentDir=" + $agentDir + "; agentDirSource=" + $agentDirSource) if (-not (Test-Path $currentPackage)) { Fail "the existing agent is missing package.json. Run install.posly.xyz/agent.ps1 to repair it." } $script:CurrentVersion = (Get-Content $currentPackage -Raw | ConvertFrom-Json).version if ($autoMode -and $script:CurrentVersion -eq "0.6.25") { # v0.6.25 checks idle only before launching PowerShell and cannot drain new # print work during staging. The first bridge therefore requires an explicit # machine-level opt-in on a server-selected canary removed from print routing # until the replacement heartbeat is accepted. if ($env:POSLY_V0625_BRIDGE_CANARY_APPROVED -ne "1") { Fail "automatic 0.6.25 bridge requires POSLY_V0625_BRIDGE_CANARY_APPROVED=1 on a server-selected canary removed from print routing for the full attempt" } Say "0.6.25 bridge canary opt-in verified; keep this machine out of print routing until replacement heartbeat acceptance" } $script:CurrentEntrySha256 = (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash $script:CurrentPackageSha256 = (Get-FileHash -LiteralPath $currentPackage -Algorithm SHA256).Hash $updateId = $script:AttemptId $tmpZip = Join-Path $env:TEMP "posly-print-agent-update-$updateId.zip" $tmpManifest = Join-Path $env:TEMP "posly-print-agent-update-$updateId.sha256" $tmpExtract = Join-Path $env:TEMP "posly-print-agent-update-$updateId" Write-UpdateState "download_started" Say "downloading print agent $agentVersion..." try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $noCacheHeaders = @{ "Cache-Control" = "no-cache"; "Pragma" = "no-cache" } Invoke-WebRequest -Uri $archiveUrl -Headers $noCacheHeaders -OutFile $tmpZip -UseBasicParsing -TimeoutSec 90 } catch { Fail "download failed: $_" } $archiveLength = (Get-Item $tmpZip).Length $archiveSignature = Get-ArchiveSignature $tmpZip Say (" got " + $archiveLength + " bytes with signature " + $archiveSignature.Hex) if ($archiveLength -lt 1024) { Fail "downloaded update is too small to be a print agent archive" } if ($archiveLength -gt 52428800) { Fail "downloaded update exceeds the 50 MB safety limit" } if (-not $archiveSignature.IsZip) { Fail "downloaded update is not a ZIP archive" } try { Invoke-WebRequest -Uri $manifestUrl -Headers $noCacheHeaders -OutFile $tmpManifest -UseBasicParsing -TimeoutSec 60 } catch { Fail "update checksum manifest download failed: $_" } $escapedArchiveName = [regex]::Escape($archiveName) $manifestPattern = "^(?[0-9a-fA-F]{64})\s+(?:\./)?$escapedArchiveName$" $manifestEntries = @( Get-Content -LiteralPath $tmpManifest | ForEach-Object { $entry = [regex]::Match($_, $manifestPattern) if ($entry.Success) { $entry.Groups["hash"].Value.ToLowerInvariant() } } ) if ($manifestEntries.Count -ne 1) { Fail "update checksum manifest has no unique entry for $archiveName" } $expectedSha256 = $manifestEntries[0] $actualSha256 = (Get-FileHash -LiteralPath $tmpZip -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $expectedSha256) { Fail "downloaded update failed SHA-256 verification" } Say " SHA-256 verified" # Clean any prior extract dir before re-using it. if (Test-Path $tmpExtract) { Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue } New-Item -ItemType Directory -Path $tmpExtract | Out-Null Say "extracting..." Expand-Archive -LiteralPath $tmpZip -DestinationPath $tmpExtract -Force $newRoot = Join-Path $tmpExtract "posly-print-agent" $newDist = Join-Path $newRoot "dist" if (-not (Test-Path $newDist)) { Fail "extracted update has no dist/ folder at $newDist" } $newPkg = Join-Path $newRoot "package.json" if (-not (Test-Path $newPkg)) { Fail "downloaded update is missing package.json" } $downloadedVersion = (Get-Content $newPkg -Raw | ConvertFrom-Json).version if ($agentVersion -ne "latest" -and $downloadedVersion -ne $agentVersion) { Fail "package.json version $downloadedVersion does not match requested version $agentVersion" } $currentDependencies = ((Get-Content $currentPackage -Raw | ConvertFrom-Json).dependencies | ConvertTo-Json -Compress) $downloadedDependencies = ((Get-Content $newPkg -Raw | ConvertFrom-Json).dependencies | ConvertTo-Json -Compress) $dependenciesChanged = $currentDependencies -ne $downloadedDependencies if ($autoMode -and $dependenciesChanged) { Fail "this update changes dependencies and requires the visible manual updater" } # Stage every essential file before interrupting the current process. $oldDist = Join-Path $agentDir "dist" $nextDist = Join-Path $agentDir "dist.next" $previousDist = Join-Path $agentDir "dist.previous" $oldPackage = Join-Path $agentDir "package.json" $nextPackage = Join-Path $agentDir "package.json.next" $previousPackage = Join-Path $agentDir "package.json.previous" if (Test-Path -LiteralPath $nextDist) { Remove-Item $nextDist -Recurse -Force -ErrorAction Stop } if (Test-Path -LiteralPath $previousDist) { Remove-Item $previousDist -Recurse -Force -ErrorAction Stop } if (Test-Path -LiteralPath $nextPackage) { Remove-Item $nextPackage -Force -ErrorAction Stop } if (Test-Path -LiteralPath $previousPackage) { Remove-Item $previousPackage -Force -ErrorAction Stop } Copy-Item -Path $newDist -Destination $nextDist -Recurse -Force Copy-Item -Path $newPkg -Destination $nextPackage -Force Copy-Item -Path $oldDist -Destination $previousDist -Recurse -Force Copy-Item -Path $oldPackage -Destination $previousPackage -Force Assert-AgentFilesVersion $nextDist $nextPackage $downloadedVersion "staged update" Assert-AgentFilesVersion $previousDist $previousPackage $script:CurrentVersion "rollback backup" $script:CurrentEntrySha256 $script:CurrentPackageSha256 # Resolve and validate the exact restart path before interrupting the agent. $batPath = Join-Path $agentDir "launch.bat" $startupDir = [Environment]::GetFolderPath("Startup") $vbsPath = if ([string]::IsNullOrWhiteSpace($startupDir)) { $null } else { Join-Path $startupDir "posly-print-agent.vbs" } $logPath = Join-Path $agentDir "agent.log" $nodeExe = Get-NodeExecutable $agentDir $targetAgentProcessId $hasExplicitAgentProcessId if (-not $nodeExe) { Fail "node.exe was not found. Run install.posly.xyz/agent.ps1 to repair Node and the print agent." } try { Initialize-LegacyPairingMigration $agentDir $nodeExe $script:RecoveryDir = Join-Path $agentDir (".update-recovery-" + $script:AttemptId) Invoke-LegacyPairingMigration $agentDir $script:RecoveryDir Backup-SupportPaths $agentDir (Join-Path $script:RecoveryDir "support-paths") $script:SupportPaths } catch { Fail "could not preserve the active Windows agent configuration safely: $_" } Write-ExactLauncher $agentDir $nodeExe $batPath $logPath Say ("launcher prepared for exact install " + $batPath) if (-not [string]::IsNullOrWhiteSpace($vbsPath)) { Write-ExactStartupWrapper $batPath $vbsPath Say ("login launcher aligned to " + $batPath) } Write-UpdateState "staged" # Do not interrupt printing until the complete archive is local and staged. Say "stopping running agent..." try { if ($autoMode) { # Agents before v0.6.13 did not pass their PID. Their updater PowerShell # process is a direct child, so derive and validate that parent once. if (-not $hasExplicitAgentProcessId) { $updaterProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $updaterPid" $targetAgentProcessId = [int]$updaterProcess.ParentProcessId Say (" derived legacy agent pid " + $targetAgentProcessId) } $agentProcess = if ($hasExplicitAgentProcessId) { Get-ValidatedAgentProcess $agentDir $targetAgentProcessId $nodeExe } else { Get-CimInstance Win32_Process -Filter "ProcessId = $targetAgentProcessId" } if (-not $agentProcess -or $agentProcess.Name -ne "node.exe") { Fail "automatic updater could not validate agent pid $targetAgentProcessId" } if ($hasExplicitAgentProcessId -and $env:POSLY_AGENT_STARTED_AT) { try { $expectedAgentStartedAt = [DateTimeOffset]::Parse($env:POSLY_AGENT_STARTED_AT).ToUniversalTime() $actualAgentStartedAt = ([DateTimeOffset]([DateTime]$agentProcess.CreationDate)).ToUniversalTime() $startDeltaSeconds = [Math]::Abs(($actualAgentStartedAt - $expectedAgentStartedAt).TotalSeconds) if ($startDeltaSeconds -gt 5) { Fail "automatic updater rejected reused or mismatched agent pid $targetAgentProcessId" } } catch { Fail "automatic updater could not validate agent start time for pid ${targetAgentProcessId}: $_" } } if ($hasExplicitAgentProcessId -and $env:POSLY_NODE_BIN -and -not [string]::IsNullOrWhiteSpace([string]$agentProcess.ExecutablePath)) { $expectedNodePath = [System.IO.Path]::GetFullPath($env:POSLY_NODE_BIN) $actualNodePath = [System.IO.Path]::GetFullPath([string]$agentProcess.ExecutablePath) if (-not $actualNodePath.Equals($expectedNodePath, [System.StringComparison]::OrdinalIgnoreCase)) { Fail "automatic updater rejected mismatched Node executable for pid $targetAgentProcessId" } } if (-not $hasExplicitAgentProcessId -and $agentProcess.CommandLine -notlike "*$agentDir*") { Fail "automatic updater could not validate legacy agent path for pid $targetAgentProcessId" } Say (" killing exact agent pid " + $targetAgentProcessId) $script:PostStopBoundary = $true Stop-Process -Id $targetAgentProcessId -Force -ErrorAction Stop Stop-OtherRunningAgents -InstallDir $agentDir -ExceptProcessId 0 } else { $script:PostStopBoundary = $true Stop-OtherRunningAgents -InstallDir $agentDir -ExceptProcessId 0 } Start-Sleep -Seconds 1 } catch { Fail "could not stop the running print agent: $_" } Say "replacing dist/..." Write-UpdateState "activating" try { Remove-Item $oldDist -Recurse -Force -ErrorAction Stop Remove-Item $oldPackage -Force -ErrorAction Stop if ($script:TestFault -eq "activation-failure") { throw "injected activation failure" } Move-Item -Path $nextDist -Destination $oldDist -Force Move-Item -Path $nextPackage -Destination $oldPackage -Force Assert-AgentFilesVersion $oldDist $oldPackage $downloadedVersion "activated print agent" } catch { Fail "could not activate downloaded agent files: $_" } # Refresh support files while preserving credentials, printer-config.json, # launch.bat and Startup registration. try { foreach ($path in $script:SupportPaths) { $source = Join-Path $newRoot $path if (Test-Path $source) { $destination = Join-Path $agentDir $path Remove-Item $destination -Recurse -Force -ErrorAction SilentlyContinue Copy-Item -Path $source -Destination $destination -Recurse -Force -ErrorAction Stop } } } catch { Fail "could not refresh support file ${path}: $_" } # Most releases only replace compiled code. Avoid an unnecessary networked # npm install, which can hang on venue connections. Dependency-changing # automatic updates are rejected before stopping the current agent. if ($dependenciesChanged) { Say "refreshing changed npm dependencies..." try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force } catch {} Push-Location $agentDir try { & cmd /c "npm install --omit=dev --no-audit --no-fund" if ($LASTEXITCODE -ne 0) { Fail "npm install failed with exit $LASTEXITCODE" } } finally { Pop-Location } } else { Say "dependencies unchanged; skipping npm install" } Say "restarting agent..." # Restart the directory that was just updated. A stale Startup wrapper from # an older install must not launch a second, outdated copy. Write-UpdateState "restart_requested" try { if ($script:TestFault -eq "restart-failure" -or $script:TestFault -eq "restore-failure") { throw "injected restart failure" } [void](Start-ExactAgentAndProve $agentDir $batPath $downloadedVersion) } catch { Restore-PreviousFiles "restart failed" Fail "Update failed during restart. Previous agent files were restored and the exact install was restarted." } if ($autoMode) { Say "waiting for post-update heartbeat..." if (-not (Wait-HeartbeatConfirmed $heartbeatTimeoutSeconds)) { Stop-OtherRunningAgents -InstallDir $agentDir -ExceptProcessId 0 Restore-PreviousFiles "post-update heartbeat was not confirmed" Fail "Update failed because the post-update heartbeat was not confirmed. Previous agent files were restored and the exact install was restarted." } Say "post-update heartbeat confirmed" } $script:PostStopBoundary = $false $script:PairingMigrationState = "none" $script:PrinterMigrationState = "none" $script:SupportPathBackups = @() Remove-Item $previousDist -Recurse -Force -ErrorAction SilentlyContinue Remove-Item $previousPackage -Force -ErrorAction SilentlyContinue Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue Remove-Item $tmpManifest -Force -ErrorAction SilentlyContinue Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue if ($script:RecoveryDir) { Remove-Item -LiteralPath $script:RecoveryDir -Recurse -Force -ErrorAction SilentlyContinue } Release-UpdateLock Write-Host "" Say "update done. Agent should dial back in within 30s." Write-AutoLog "INFO" ("update complete; installedVersion=" + $downloadedVersion) Write-Host " Tail log: Get-Content `"$agentDir\agent.log`" -Wait" Write-Host "" if (-not $autoMode) { Read-Host "Press Enter to close" }