Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 51 additions & 28 deletions scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
@"
Expand All @@ -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),
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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)"
}
Expand Down Expand Up @@ -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 = @(
Expand Down
74 changes: 71 additions & 3 deletions scripts/tests/OpenHumanWindowsInstall.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)'
Expand Down Expand Up @@ -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
Expand Down
Loading