diff --git a/.github/workflows/installer-smoke.yml b/.github/workflows/installer-smoke.yml index a8c048d9f..a4c971c81 100644 --- a/.github/workflows/installer-smoke.yml +++ b/.github/workflows/installer-smoke.yml @@ -23,13 +23,17 @@ jobs: - name: Run installer dry-run run: bash scripts/install.sh --dry-run --verbose - # smoke-windows: - # name: Smoke install.ps1 (windows-latest) - # runs-on: windows-latest - # steps: - # - name: Checkout - # uses: actions/checkout@v4 + smoke-windows: + name: install.ps1 tests + dry-run (windows-latest) + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - # - name: Run installer dry-run - # shell: pwsh - # run: ./scripts/install.ps1 -DryRun + - name: Unit tests (MSI args / asset selection) + shell: pwsh + run: pwsh -NoProfile -File ./scripts/tests/OpenHumanWindowsInstall.Tests.ps1 + + - name: Run installer dry-run + shell: pwsh + run: ./scripts/install.ps1 -DryRun diff --git a/package.json b/package.json index 042d78b91..148af9e1e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "review": "bash scripts/review/cli.sh", "work": "bash scripts/work/cli.sh", "debug": "bash scripts/debug/cli.sh", + "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", "typecheck": "pnpm --filter openhuman-app compile" }, diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 8e38dc268..c6dedb4f5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -9,8 +9,72 @@ Also works when saved and run directly: .\scripts\install.ps1 -DryRun + + MSI installs use the Tauri WiX package (InstallScope perMachine). Per-user + public properties (MSIINSTALLPERUSER / ALLUSERS=2) conflict with that layout + and commonly fail with exit 1603 — see tinyhumansai/openhuman#913. + + When the current session is not elevated, msiexec is started with -Verb RunAs + so Windows shows UAC once (machine install to Program Files). #> +# --- Script-scoped helpers (unit-tested; safe to dot-source this file) --- + +function Get-OpenHumanMsiexecInstallArgumentList { + <# + .SYNOPSIS + Argument list for Start-Process msiexec.exe (no per-user MSI overrides). + #> + param( + [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') +} + +function Test-OpenHumanWindowsProcessElevated { + <# + .SYNOPSIS + True when the current process is running with an administrator token (Windows only). + #> + if ($env:OS -ne 'Windows_NT') { + return $false + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Select-OpenHumanWindowsAssetFromRelease { + <# + .SYNOPSIS + Pick the Windows x64 MSI from a GitHub release object, else NSIS exe. + #> + param( + [Parameter(Mandatory = $true)] + [object]$Release + ) + $assets = @($Release.assets) + if (-not $assets -or $assets.Count -eq 0) { + return $null + } + + $msi = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.msi$' } | Select-Object -First 1 + if ($msi) { + return $msi + } + + $exe = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.exe$' } | Select-Object -First 1 + if ($exe) { + return $exe + } + + return $null +} + # Wrap in a function so `param()` works when piped via `irm | iex`. # When piped, PowerShell cannot bind param() at the top-level scope. function Install-OpenHuman { @@ -23,7 +87,7 @@ function Install-OpenHuman { $ErrorActionPreference = "Stop" - $InstallerVersion = "1.0.0" + $InstallerVersion = "1.1.0" $Repo = "tinyhumansai/openhuman" $LatestReleaseApiUrl = "https://api.github.com/repos/$Repo/releases/latest" @@ -90,25 +154,10 @@ Examples: $assetUrl = "" $assetDigest = "" - function Select-WindowsAssetFromRelease([object]$Rel) { - $assets = @($Rel.assets) - if (-not $assets -or $assets.Count -eq 0) { - return $null - } - - $msi = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.msi$' } | Select-Object -First 1 - if ($msi) { return $msi } - - $exe = $assets | Where-Object { $_.name -match 'OpenHuman_.*x64.*\.exe$' } | Select-Object -First 1 - if ($exe) { return $exe } - - return $null - } - try { $release = Invoke-RestMethod -Uri $LatestReleaseApiUrl -UseBasicParsing $releaseTag = ($release.tag_name -replace '^v', '') - $selected = Select-WindowsAssetFromRelease -Rel $release + $selected = Select-OpenHumanWindowsAssetFromRelease -Release $release if ($selected) { $assetName = $selected.name $assetUrl = $selected.browser_download_url @@ -155,7 +204,13 @@ Examples: if ($DryRun) { if ($assetName -like "*.msi") { - Write-Output "DRY RUN: msiexec /i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" + $dryMsiArgs = Get-OpenHumanMsiexecInstallArgumentList -MsiPath $tmpFile + Write-Output "DRY RUN: msiexec ArgumentList = $($dryMsiArgs | ConvertTo-Json -Compress)" + if (Test-OpenHumanWindowsProcessElevated) { + Write-Output "DRY RUN: (already elevated) Start-Process msiexec -Wait -ArgumentList " + } else { + Write-Output "DRY RUN: (non-admin) Start-Process msiexec -Verb RunAs -Wait -ArgumentList " + } } else { Write-Output "DRY RUN: Start-Process `"$tmpFile`" -Wait" } @@ -164,10 +219,17 @@ Examples: Write-Info "Installing OpenHuman" if ($assetName -like "*.msi") { - $msiArgs = "/i `"$tmpFile`" MSIINSTALLPERUSER=1 ALLUSERS=2 /qn /norestart" - $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -PassThru + $msiArgs = Get-OpenHumanMsiexecInstallArgumentList -MsiPath $tmpFile + $elevated = Test-OpenHumanWindowsProcessElevated + if ($elevated) { + $proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -Wait -PassThru + } else { + 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)." + Write-WarnMsg "If this persists, capture a log: msiexec /i `"$tmpFile`" /l*v `"$env:TEMP\OpenHuman-msi.log`"" return } } elseif ($assetName -like "*.exe") { @@ -199,5 +261,7 @@ Examples: } } -# Run the installer, forwarding any arguments passed when invoked as a file. -Install-OpenHuman @args +# Run when executed as a script; skip when dot-sourced (e.g. unit tests). +if ($MyInvocation.InvocationName -ne '.') { + Install-OpenHuman @args +} diff --git a/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 b/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 new file mode 100644 index 000000000..965bbdcab --- /dev/null +++ b/scripts/tests/OpenHumanWindowsInstall.Tests.ps1 @@ -0,0 +1,99 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Unit tests for scripts/install.ps1 helpers (#913 MSI argument contract). + +.DESCRIPTION + Dot-sources install.ps1 (does not run Install-OpenHuman) and validates + Get-OpenHumanMsiexecInstallArgumentList, Select-OpenHumanWindowsAssetFromRelease, + and Test-OpenHumanWindowsProcessElevated. + + Run from repo root: + pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1 +#> +$ErrorActionPreference = 'Stop' + +$installScript = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot '..') 'install.ps1')).Path +. $installScript + +$testCount = 0 +$failCount = 0 + +function Assert-Equal { + param( + [string]$Expected, + [string]$Actual, + [string]$Message + ) + $script:testCount++ + if ($Expected -ne $Actual) { + $script:failCount++ + Write-Host "FAIL: $Message" -ForegroundColor Red + Write-Host " expected: $Expected" -ForegroundColor Red + Write-Host " actual: $Actual" -ForegroundColor Red + } else { + Write-Host "ok $Message" -ForegroundColor Green + } +} + +function Assert-True { + param([bool]$Condition, [string]$Message) + $script:testCount++ + if (-not $Condition) { + $script:failCount++ + Write-Host "FAIL: $Message" -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' +$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 '/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)' +Assert-True ($args -notcontains 'ALLUSERS=2') 'must not set ALLUSERS=2' +Assert-True ($args -notcontains 'ALLUSERS=1') 'must not set ALLUSERS=1 (use package default)' +$joined = $args -join ' ' +Assert-True ($joined -notmatch 'MSIINSTALLPERUSER') 'joined args omit MSIINSTALLPERUSER' +Assert-True ($joined -notmatch 'ALLUSERS') 'joined args omit ALLUSERS' + +Write-Host "`n== Select-OpenHumanWindowsAssetFromRelease ==" -ForegroundColor Cyan +$release = [pscustomobject]@{ + assets = @( + [pscustomobject]@{ name = 'OpenHuman_1.0.0_x64_en-US.msi'; browser_download_url = 'https://example/msi' } + [pscustomobject]@{ name = 'other.zip'; browser_download_url = 'https://example/z' } + ) +} +$sel = Select-OpenHumanWindowsAssetFromRelease -Release $release +Assert-Equal 'OpenHuman_1.0.0_x64_en-US.msi' $sel.name 'prefers MSI over other assets' + +$releaseExe = [pscustomobject]@{ + assets = @( + [pscustomobject]@{ name = 'OpenHuman_1.0.0_x64-setup.exe'; browser_download_url = 'https://example/exe' } + ) +} +$sel2 = Select-OpenHumanWindowsAssetFromRelease -Release $releaseExe +Assert-True ($null -ne $sel2) 'selects exe when no msi' +Assert-Equal 'OpenHuman_1.0.0_x64-setup.exe' $sel2.name 'exe name matches pattern' + +$releaseEmpty = [pscustomobject]@{ assets = @() } +$sel3 = Select-OpenHumanWindowsAssetFromRelease -Release $releaseEmpty +Assert-True ($null -eq $sel3) 'null when no assets' + +Write-Host "`n== Test-OpenHumanWindowsProcessElevated ==" -ForegroundColor Cyan +$t = Test-OpenHumanWindowsProcessElevated +Assert-True ($t -is [bool]) 'returns a boolean' + +Write-Host "`n== $($testCount) checks, $failCount failed ==" -ForegroundColor $(if ($failCount -eq 0) { 'Green' } else { 'Red' }) +if ($failCount -gt 0) { + exit 1 +} +exit 0