diff --git a/scripts/install.ps1 b/scripts/install.ps1 index c6dedb4f5f..d018f611c8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -29,10 +29,9 @@ function Get-OpenHumanMsiexecInstallArgumentList { [Parameter(Mandatory = $true)] [string]$MsiPath ) - # Pass -ArgumentList as string[]: each entry is one argv token for msiexec, so spaces in - # $MsiPath do not split. Do not wrap $MsiPath in extra literal " characters here — that can - # double-escape when Start-Process builds the native command line (see PR #1187 review). - return @('/i', $MsiPath, '/qn', '/norestart') + # Start-Process joins ArgumentList entries into one command line, so preserve spaces by + # quoting the MSI path explicitly before it is passed to msiexec. + return @('/i', ('"{0}"' -f $MsiPath), '/qn', '/norestart') } function Test-OpenHumanWindowsProcessElevated { @@ -48,6 +47,44 @@ function Test-OpenHumanWindowsProcessElevated { return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +function Test-OpenHumanInstallerExitCodeSucceeded { + param( + [Parameter(Mandatory = $true)] + [int]$ExitCode, + + [Parameter(Mandatory = $true)] + [ValidateSet("MSI", "EXE")] + [string]$InstallerType + ) + + return $ExitCode -eq 0 -or ($InstallerType -eq "MSI" -and $ExitCode -in @(1641, 3010)) +} + +function Assert-OpenHumanInstallerProcessSucceeded { + <# + .SYNOPSIS + Throw when a Windows installer process reports an unsuccessful exit code. + #> + param( + [Parameter(Mandatory = $true)] + [int]$ExitCode, + + [Parameter(Mandatory = $true)] + [ValidateSet("MSI", "EXE")] + [string]$InstallerType + ) + + if (Test-OpenHumanInstallerExitCodeSucceeded -ExitCode $ExitCode -InstallerType $InstallerType) { + return + } + + if ($InstallerType -eq "MSI") { + throw "MSI install failed with exit code $ExitCode." + } + + throw "Installer exited with code $ExitCode." +} + function Select-OpenHumanWindowsAssetFromRelease { <# .SYNOPSIS @@ -94,7 +131,6 @@ function Install-OpenHuman { function Write-Info([string]$Message) { Write-Host "-> $Message" -ForegroundColor Cyan } function Write-Ok([string]$Message) { Write-Host "OK $Message" -ForegroundColor Green } function Write-WarnMsg([string]$Message) { Write-Host "! $Message" -ForegroundColor Yellow } - function Write-Err([string]$Message) { Write-Host "x $Message" -ForegroundColor Red } function Show-Usage { @" @@ -120,13 +156,11 @@ Examples: } if ($Channel -ne "stable") { - Write-Err "Only -Channel stable is currently supported." - return + throw "Only -Channel stable is currently supported." } if ($env:OS -ne "Windows_NT") { - Write-Err "This installer is for Windows only." - return + throw "This installer is for Windows only." } # Detect architecture — use environment variable as primary (always available), @@ -142,8 +176,7 @@ Examples: $arch = "$arch".ToLowerInvariant() if ($arch -notin @("x64", "amd64")) { - Write-Err "Unsupported architecture: $arch (Windows x64 required)." - return + throw "Unsupported architecture: $arch (Windows x64 required)." } Write-Ok "Detected platform: windows/x64" @@ -166,13 +199,11 @@ Examples: } } } catch { - Write-WarnMsg "Could not query release API: $($_.Exception.Message)" + throw } if (-not $assetUrl) { - Write-Err "No Windows x64 installer artifact found in latest release." - Write-Err "Ensure release workflow publishes Windows MSI/EXE assets." - return + throw "No Windows x64 installer artifact found in latest release. Ensure release workflow publishes Windows MSI/EXE assets." } Write-Ok "Resolved latest release ($releaseTag): $assetName" @@ -191,10 +222,7 @@ Examples: } else { $fileHash = (Get-FileHash -Path $tmpFile -Algorithm SHA256).Hash.ToLowerInvariant() if ($fileHash -ne $assetDigest.ToLowerInvariant()) { - Write-Err "SHA256 mismatch for $assetName" - Write-Err "Expected: $assetDigest" - Write-Err "Actual: $fileHash" - return + throw "SHA256 mismatch for $assetName. Expected: $assetDigest. Actual: $fileHash." } Write-Ok "Integrity verified (sha256)" } @@ -227,20 +255,15 @@ Examples: Write-Info "Requesting administrator approval for machine-wide install (UAC)…" $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Verb RunAs -Wait -PassThru } - if ($proc.ExitCode -ne 0) { - Write-Err "MSI install failed with exit code $($proc.ExitCode)." + if (-not (Test-OpenHumanInstallerExitCodeSucceeded -ExitCode $proc.ExitCode -InstallerType "MSI")) { Write-WarnMsg "If this persists, capture a log: msiexec /i `"$tmpFile`" /l*v `"$env:TEMP\OpenHuman-msi.log`"" - return } + Assert-OpenHumanInstallerProcessSucceeded -ExitCode $proc.ExitCode -InstallerType "MSI" } elseif ($assetName -like "*.exe") { $proc = Start-Process -FilePath $tmpFile -Wait -PassThru - if ($proc.ExitCode -ne 0) { - Write-Err "Installer exited with code $($proc.ExitCode)." - return - } + Assert-OpenHumanInstallerProcessSucceeded -ExitCode $proc.ExitCode -InstallerType "EXE" } else { - Write-Err "Unsupported Windows installer type: $assetName" - return + throw "Unsupported Windows installer type: $assetName" } $expectedPaths = @( diff --git a/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 b/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 index 965bbdcab5..3d615948c4 100644 --- a/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 +++ b/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 @@ -6,7 +6,7 @@ .DESCRIPTION Dot-sources install.ps1 (does not run Install-OpenHuman) and validates Get-OpenHumanMsiexecInstallArgumentList, Select-OpenHumanWindowsAssetFromRelease, - and Test-OpenHumanWindowsProcessElevated. + Test-OpenHumanWindowsProcessElevated, and installer failure propagation. Run from repo root: pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1 @@ -47,15 +47,50 @@ function Assert-True { } } +function Assert-DoesNotThrow { + param([scriptblock]$Action, [string]$Message) + $script:testCount++ + try { + & $Action | Out-Null + Write-Host "ok $Message" -ForegroundColor Green + } catch { + $script:failCount++ + Write-Host "FAIL: $Message" -ForegroundColor Red + Write-Host " unexpected error: $($_.Exception.Message)" -ForegroundColor Red + } +} + +function Assert-Throws { + param([scriptblock]$Action, [string]$ExpectedMessage, [string]$Message) + $script:testCount++ + try { + & $Action | Out-Null + $script:failCount++ + Write-Host "FAIL: $Message" -ForegroundColor Red + Write-Host " expected error: $ExpectedMessage" -ForegroundColor Red + Write-Host " actual: no terminating error" -ForegroundColor Red + } catch { + $actualMessage = $_.Exception.Message + if ($ExpectedMessage -ne $actualMessage) { + $script:failCount++ + Write-Host "FAIL: $Message" -ForegroundColor Red + Write-Host " expected error: $ExpectedMessage" -ForegroundColor Red + Write-Host " actual error: $actualMessage" -ForegroundColor Red + } else { + Write-Host "ok $Message" -ForegroundColor Green + } + } +} + Write-Host "`n== Get-OpenHumanMsiexecInstallArgumentList (#913) ==" -ForegroundColor Cyan $p = 'C:\Temp\OpenHuman_0.0.0_x64_en-US.msi' $args = Get-OpenHumanMsiexecInstallArgumentList -MsiPath $p Assert-True ($args.Count -eq 4) 'returns exactly 4 argument tokens' Assert-Equal '/i' $args[0] 'first token is /i' -Assert-Equal $p $args[1] 'second token is MSI path' +Assert-Equal ('"{0}"' -f $p) $args[1] 'second token is quoted MSI path' $pSpaces = 'C:\Temp\Test User\OpenHuman_0.0.0_x64_en-US.msi' $argsSpaces = Get-OpenHumanMsiexecInstallArgumentList -MsiPath $pSpaces -Assert-Equal $pSpaces $argsSpaces[1] 'path with spaces remains one second argv token (no split)' +Assert-Equal ('"{0}"' -f $pSpaces) $argsSpaces[1] 'path with spaces remains quoted for Start-Process' Assert-Equal '/qn' $args[2] 'third token is /qn' Assert-Equal '/norestart' $args[3] 'fourth token is /norestart' Assert-True ($args -notcontains 'MSIINSTALLPERUSER') 'must not set MSIINSTALLPERUSER (perMachine MSI)' @@ -92,6 +127,39 @@ Write-Host "`n== Test-OpenHumanWindowsProcessElevated ==" -ForegroundColor Cyan $t = Test-OpenHumanWindowsProcessElevated Assert-True ($t -is [bool]) 'returns a boolean' +Write-Host "`n== Installer failure propagation ==" -ForegroundColor Cyan +Assert-DoesNotThrow { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 0 -InstallerType 'MSI' } 'accepts a successful child process' +Assert-DoesNotThrow { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 1641 -InstallerType 'MSI' } 'accepts an MSI success that initiated a reboot' +Assert-DoesNotThrow { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 3010 -InstallerType 'MSI' } 'accepts an MSI success that requires a reboot' +Assert-Throws { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 1603 -InstallerType 'MSI' } 'MSI install failed with exit code 1603.' 'turns an MSI failure into a terminating error' +Assert-Throws { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 5 -InstallerType 'EXE' } 'Installer exited with code 5.' 'turns an EXE failure into a terminating error' +Assert-Throws { Assert-OpenHumanInstallerProcessSucceeded -ExitCode 3010 -InstallerType 'EXE' } 'Installer exited with code 3010.' 'does not treat MSI reboot codes as EXE success' + +$versionOutput = (Install-OpenHuman -Version | Out-String).Trim() +Assert-Equal 'openhuman-installer 1.1.0' $versionOutput '-Version remains successful' +Assert-Throws { Install-OpenHuman -Channel 'preview' } 'Only -Channel stable is currently supported.' 'invalid arguments terminate instead of returning success' + +$originalArch = $env:PROCESSOR_ARCHITECTURE +$originalOs = $env:OS +try { + $env:OS = 'Windows_NT' + $env:PROCESSOR_ARCHITECTURE = 'AMD64' + function Invoke-RestMethod { throw 'release API unavailable' } + Assert-Throws { Install-OpenHuman -DryRun } 'release API unavailable' 'release API failures preserve their error record' +} finally { + Remove-Item Function:\Invoke-RestMethod -ErrorAction SilentlyContinue + $env:PROCESSOR_ARCHITECTURE = $originalArch + $env:OS = $originalOs +} + +$originalOs = $env:OS +try { + $env:OS = 'OpenHumanTestUnsupported' + Assert-Throws { Get-Content -Raw $installScript | Invoke-Expression } 'This installer is for Windows only.' 'piped irm|iex-style execution preserves terminating errors' +} finally { + $env:OS = $originalOs +} + Write-Host "`n== $($testCount) checks, $failCount failed ==" -ForegroundColor $(if ($failCount -eq 0) { 'Green' } else { 'Red' }) if ($failCount -gt 0) { exit 1