# Hoody CLI installer (Windows PowerShell). Verifies a minisign-signed # channel.json before downloading + installing the Windows binary. # # Generated for domain=hoody.icu on 2026-04-30T21:46:01Z $ErrorActionPreference = 'Stop' # Pinned trust anchor (must equal the domain's CDN_BIN_MINISIGN_PUBKEY): $PINNED_PUBKEY = 'RWRpngXprNcILzU05dVs9Bkws5tS4KqPkg2FVm7vnS1sowCpg42ax3Ek' # Comma-separated install hosts that this script trusts (from CDN_BIN_HOSTS): $BIN_HOSTS = 'install.hoody.icu,bin.hoody.icu,dl.hoody.icu,download.hoody.icu' # Domain pin (informational; baked into the binary at compile time): $HOODY_DOMAIN = 'hoody.icu' # Round 4 B3: minisign verifier-tool pin lines REQUIRED by hoody-cdn's # startup invariant. The MINISIGN_SHA256_LINUX_X86_64 (and friends) # placeholders below are substituted by hoody-cdn at startup (NOT by # Phase 6b2.5d) with the actual SHA-256 of each on-disk verifier binary. # install_pin.rs::ps_re() regex format: # $MINISIGN_SHA256_LINUX_X86_64 = "<64-hex>" # NB: do NOT write the literal double-open-curly token anywhere in this # script except as one of the recognized placeholder names (CDN_BASE or # MINISIGN_SHA256_*). Any other occurrence survives substitute_placeholders # and trips startup.rs:241's "any unresolved placeholder?" check, which # refuses to serve install.ps1. $MINISIGN_SHA256_LINUX_X86_64 = "2c74dffcc1c9a5ee55957c60971998ace2b89f22585631594ec2152c588af8db" $MINISIGN_SHA256_LINUX_AARCH64 = "cec9f88be8c975af76854a53b4d49c3d257feae38d916edb0d16fb55aacd3000" $MINISIGN_SHA256_DARWIN_X86_64 = "1c6e686763361a407b237e95cf8f6d9511d6b660b900a68e6bd9cc494bddcfc7" $MINISIGN_SHA256_DARWIN_AARCH64 = "1c6e686763361a407b237e95cf8f6d9511d6b660b900a68e6bd9cc494bddcfc7" $MINISIGN_SHA256_WINDOWS_X86_64 = "5535be9e4e123831ebe6ef324aafe9dde507015c176191f9e20c3ad60567f9e1" # Realm-canonical bin host. https://install.hoody.icu substituted by hoody-cdn at startup. $DEFAULT_CDN_BASE = 'https://install.hoody.icu' # Verify placeholders were substituted at deploy time. If any token of # the form __FOO__ remains, the deploy step (Phase 6b2.5d) failed. # Round 5 angle-03 #6: anchor regex to ^__[A-Z_]+__$ so a legitimate # value like 'my__test__.example.com' doesn't false-trigger. foreach ($pair in @(@('PINNED_PUBKEY', $PINNED_PUBKEY), @('BIN_HOSTS', $BIN_HOSTS), @('HOODY_DOMAIN', $HOODY_DOMAIN))) { if ($pair[1] -match '^__[A-Z_]+__$') { throw "install: $($pair[0]) placeholder not substituted (deploy bug)" } } # Detect arch. PowerShell's $env:PROCESSOR_ARCHITECTURE returns AMD64 / ARM64. # Bin matrix only ships windows-x86_64 today; reject other windows arches # explicitly with a clear message rather than 404'ing on download. $arch = switch -Regex ($env:PROCESSOR_ARCHITECTURE) { '^(AMD64|x86_64)$' { 'x86_64'; break } '^ARM64$' { throw "install: windows ARM64 binaries are not yet released. Use Windows on Intel/AMD or run hoody from WSL." } default { throw "install: unsupported arch: $env:PROCESSOR_ARCHITECTURE" } } $archiveName = "hoody-windows-$arch.zip" # Round 10 angle-04 MED: hoist HOODY_INSTALL_CACERT trust-callback setup # AND TLS-1.2 enforcement BEFORE the host probe so the test fixture's # self-signed CA is honored on the very first request. PS 5.1 defaults # ServicePointManager to Tls10/11 — force 1.2/1.3. [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 $caCert = $env:HOODY_INSTALL_CACERT if ($caCert) { if (-not (Test-Path -LiteralPath $caCert -PathType Leaf)) { throw "install: HOODY_INSTALL_CACERT=$caCert not readable (test-only env var)" } Add-Type -AssemblyName System.Security $script:caCertObj = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($caCert) [Net.ServicePointManager]::ServerCertificateValidationCallback = { param($sender, $cert, $chain, $errors) if ($errors -eq [Net.Security.SslPolicyErrors]::None) { return $true } $verifyChain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() $verifyChain.ChainPolicy.ExtraStore.Add($script:caCertObj) | Out-Null $verifyChain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck $verifyChain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority $ok = $verifyChain.Build([System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert)) if ($ok) { $rootInChain = $verifyChain.ChainElements | Where-Object { $_.Certificate.Thumbprint -eq $script:caCertObj.Thumbprint } | Select-Object -First 1 return [bool]$rootInChain } return $false } } # Pick the first reachable bin host. $selectedHost = $null foreach ($h in ($BIN_HOSTS -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) { try { $r = Invoke-WebRequest -Method Head -Uri "https://$h/channel.json" -UseBasicParsing -ErrorAction Stop -TimeoutSec 15 -MaximumRedirection 5 if ($r.StatusCode -eq 200) { $selectedHost = $h; break } } catch { continue } } if (-not $selectedHost) { throw "install: no install host reachable: tried $BIN_HOSTS" } Write-Host "install: using host=$selectedHost archive=$archiveName" # Persist the active domain (symlink-safe write — Windows lacks symlinks for # unprivileged users, but Junctions can spoof; check ReparsePoint attribute). $configDir = Join-Path $env:APPDATA 'hoody' $dirCreated = -not (Test-Path $configDir) New-Item -ItemType Directory -Force -Path $configDir | Out-Null # Round 6 angle-08 HIGH: ACL hardening — Windows New-Item inherits parent # ACLs (typically Users:RX + Authenticated Users), letting any local user # read+overwrite the trust pin. icacls disables inheritance and grants # full control only to the current user. Skip on existing dirs (operator # may have customized) to avoid surprising existing setups. if ($dirCreated) { try { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value & icacls.exe $configDir /inheritance:r "/grant:r" "*${sid}:(OI)(CI)F" 2>&1 | Out-Null } catch { Write-Host "install: warning: failed to harden ACL on $configDir (continuing)" } } $domainFile = Join-Path $configDir 'domain' $fingerprintFile = "$domainFile.fingerprint" foreach ($f in @($domainFile, $fingerprintFile)) { if (Test-Path $f) { $info = Get-Item $f -Force if ($info.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "install: $f is a reparse point (junction/symlink); refusing to overwrite" } } } # Round 4 portability: BOM-less UTF-8 across PS 5.1 and 7+ (default # [IO.File]::WriteAllText emits BOM on 5.1, none on 7 — the binary's # trim() doesn't strip BOM, so domain pin compares fail silently). $utf8NoBom = New-Object Text.UTF8Encoding($false) # Round 6 angle-08 HIGH: atomic write via .tmp + Move-Item -Force, with # byte-count validation. Defends against a partial write (process death, # disk-full) leaving a torn final file that the SDK loader then trusts. # Open with CreateNew on the .tmp so a planted reparse-point .tmp can't # silently redirect the write. function Write-AtomicTextFile { param([string]$Path, [string]$Body, $Encoding) $tmp = "$Path.tmp" if (Test-Path $tmp) { $info = Get-Item $tmp -Force if ($info.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "install: $tmp is a reparse point; refusing to follow (TOCTOU defense)" } Remove-Item -Force -LiteralPath $tmp } $bytes = $Encoding.GetBytes($Body) $stream = [IO.FileStream]::new($tmp, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) try { $stream.Write($bytes, 0, $bytes.Length) $stream.Flush($true) } finally { $stream.Dispose() } $written = (Get-Item -LiteralPath $tmp).Length if ($written -ne $bytes.Length) { Remove-Item -Force -LiteralPath $tmp -ErrorAction SilentlyContinue throw "install: atomic write incomplete for $Path (expected $($bytes.Length) bytes, wrote $written)" } Move-Item -Force -LiteralPath $tmp -Destination $Path } Write-AtomicTextFile -Path $domainFile -Body $HOODY_DOMAIN -Encoding $utf8NoBom # Round 4 H2: write install-time pubkey fingerprint adjacent to domain file. $pubkeyBytes = [Convert]::FromBase64String($PINNED_PUBKEY) # Round 6 angle-08 LOW: assert the decoded bytes are exactly 42 BEFORE # hashing — POSIX sibling installer enforces this at install.sh.tmpl:174. if ($pubkeyBytes.Length -ne 42) { throw "install: decoded PINNED_PUBKEY is $($pubkeyBytes.Length) bytes, expected 42 (broken installer)" } $sha = [Security.Cryptography.SHA256]::Create() try { $hashBytes = $sha.ComputeHash($pubkeyBytes) } finally { $sha.Dispose() } $fingerprint = -join ($hashBytes | ForEach-Object { '{0:x2}' -f $_ }) if ($fingerprint.Length -ne 64) { throw "install: failed to compute pubkey fingerprint" } Write-AtomicTextFile -Path $fingerprintFile -Body $fingerprint -Encoding $utf8NoBom Write-Host "install: wrote $domainFile = $HOODY_DOMAIN" Write-Host ("install: wrote $fingerprintFile = " + $fingerprint.Substring(0, 16) + "...") # ============================================================================= # Phase 4: download + verify + extract + install (Windows) # ============================================================================= # # Trust chain mirrors install.sh.tmpl: # PINNED_PUBKEY (trust anchor — signed into install.ps1 by Phase 6b2.5d) # ↓ # verifier tool (downloaded from /tools/, hash-pinned via # $MINISIGN_SHA256_WINDOWS_X86_64 baked at startup) # ↓ # channel.json + .minisig (verified by tool with PINNED_PUBKEY, # trusted_comment "hoody-cdn bin version=") # ↓ # /SHA256SUMS + .minisig (per-version) # ↓ # (hash matched against SHA256SUMS line) # ↓ # extracted .exe → atomic move to %LOCALAPPDATA%\Programs\Hoody\\ # ↓ # %LOCALAPPDATA%\Programs\Hoody\hoody.exe (current copy — Windows can't # symlink without elevation, so we Copy-Item the binary as the "current" # version and tell the operator to add the dir to PATH). # HOODY_INSTALL_CACERT trust-callback was hoisted above the host probe. # Re-using the same callback for fetch — Invoke-WebRequest reads through # the process-wide ServicePointManager.ServerCertificateValidationCallback. function Invoke-Fetch { param([string]$Uri, [string]$OutFile) Invoke-WebRequest -Uri $Uri -OutFile $OutFile -UseBasicParsing -ErrorAction Stop -TimeoutSec 600 -MaximumRedirection 5 | Out-Null } # Compute SHA-256 of a file as lowercase hex (Get-FileHash returns uppercase). function Get-FileSha256Lower { param([string]$Path) return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLower() } # Resolve install destinations. %LOCALAPPDATA% is per-user, present on every # Windows version we support; on Server Core / unattended it may be unset # in non-interactive sessions — guard explicitly. if (-not $env:LOCALAPPDATA) { throw "install: %LOCALAPPDATA% is not set; cannot resolve install dir" } $installBase = Join-Path $env:LOCALAPPDATA 'Programs\Hoody' $versionsDir = Join-Path $installBase 'versions' $installBaseExisted = Test-Path $installBase $null = New-Item -ItemType Directory -Force -Path $installBase $null = New-Item -ItemType Directory -Force -Path $versionsDir # v11 R10 angle-04 MED carryover: ACL-harden $installBase on FRESH # create — same pattern as $configDir hardening above. On a multi-user # Windows host another local user can otherwise write into subdirs that # inherit Users:Modify, opening a planted-DLL search-path attack on # hoody.exe load-from-own-dir behavior. Skip on existing dirs (operator # may have customized) so we don't surprise existing installs. if (-not $installBaseExisted) { try { $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value & icacls.exe $installBase /inheritance:r "/grant:r" "*${sid}:(OI)(CI)F" 2>&1 | Out-Null } catch { Write-Host "install: warning: failed to harden ACL on $installBase (continuing)" } } # Concurrency lock — a directory the OS treats as atomic via CreateDirectory. # A stale lock from a crashed run can be removed manually; we surface the # path so the operator can find it. $lockDir = Join-Path $installBase '.install.lock' if (Test-Path -LiteralPath $lockDir) { throw "install: another install in progress (lock: $lockDir). If stale: Remove-Item $lockDir" } $null = New-Item -ItemType Directory -Path $lockDir # Stage everything in a tmpdir + cleanup on any error. # Round 10 HIGH: stage UNDER $installBase so Move-Item to $finalDir is # atomic (same volume = MoveFileEx rename). Pre-fix used %TMP% which on # multi-disk workstations lives on a different drive, degrading the # move into copy+delete (non-atomic, partial state observable). $stageBase = Join-Path $installBase '.staging' $null = New-Item -ItemType Directory -Force -Path $stageBase $stageDir = Join-Path $stageBase ("install-" + [System.Guid]::NewGuid().ToString()) $null = New-Item -ItemType Directory -Path $stageDir function Cleanup-Install { if ($script:stageDir -and (Test-Path -LiteralPath $script:stageDir)) { Remove-Item -Recurse -Force -LiteralPath $script:stageDir -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $script:lockDir) { Remove-Item -Force -LiteralPath $script:lockDir -ErrorAction SilentlyContinue } } trap { Cleanup-Install; break } try { # --------------------------------------------------------------------- # Step 1: download verifier tool, check hash against pin. # --------------------------------------------------------------------- $pinnedToolHash = $MINISIGN_SHA256_WINDOWS_X86_64 if (-not $pinnedToolHash -or $pinnedToolHash -match '\{\{') { throw "install: MINISIGN_SHA256_WINDOWS_X86_64 not substituted by hoody-cdn at startup (broken realm setup)" } if ($pinnedToolHash -notmatch '^[0-9a-f]{64}$') { throw "install: MINISIGN_SHA256_WINDOWS_X86_64 ($pinnedToolHash) is not 64-char lowercase hex" } $verifierName = "minisign-windows-$arch.exe" $verifierPath = Join-Path $stageDir $verifierName Invoke-Fetch -Uri "https://$selectedHost/tools/$verifierName" -OutFile $verifierPath $gotToolHash = Get-FileSha256Lower -Path $verifierPath if ($gotToolHash -ne $pinnedToolHash) { throw "install: verifier hash mismatch: expected $pinnedToolHash, got $gotToolHash (corrupt download or compromised CDN)" } # Mark-of-the-Web: PS-downloaded files get NTFS Zone.Identifier ADS that # SmartScreen / EDR may quarantine; Unblock-File strips it so the exe # runs without prompting. The hash we just verified is the integrity # anchor — MOTW is just UI friction. Unblock-File -LiteralPath $verifierPath -ErrorAction SilentlyContinue # --------------------------------------------------------------------- # Step 2: write pinned pubkey to a file (minisign -V wants a file). # --------------------------------------------------------------------- $pubPath = Join-Path $stageDir 'PINNED_PUBKEY.pub' [IO.File]::WriteAllText($pubPath, "untrusted comment: pinned pubkey from install.ps1`n$PINNED_PUBKEY`n", $utf8NoBom) # --------------------------------------------------------------------- # Step 3: fetch + verify channel.json. # --------------------------------------------------------------------- $chanPath = Join-Path $stageDir 'channel.json' $chanSig = Join-Path $stageDir 'channel.json.minisig' Invoke-Fetch -Uri "https://$selectedHost/channel.json" -OutFile $chanPath Invoke-Fetch -Uri "https://$selectedHost/channel.json.minisig" -OutFile $chanSig $verify = & $verifierPath -V -p $pubPath -m $chanPath -x $chanSig 2>&1 if ($LASTEXITCODE -ne 0) { throw "install: channel.json.minisig FAILED verification (corrupt download or compromised CDN). minisign output: $verify" } # Parse channel.json. PS has ConvertFrom-Json built-in. $chan = Get-Content -Raw -LiteralPath $chanPath | ConvertFrom-Json if ($chan.schema_version -ne 1) { throw "install: channel.json schema_version=$($chan.schema_version) (expected 1) — installer too old or realm misconfigured" } if (-not $chan.latest) { throw "install: channel.json missing 'latest'" } if (-not $chan.not_after) { throw "install: channel.json missing 'not_after'" } if ($chan.latest -notmatch '^[0-9a-zA-Z._+-]+$') { throw "install: channel.json 'latest' contains invalid chars: $($chan.latest)" } # Freshness: not_after must be in the future. ISO 8601 → DateTime. $notAfter = [DateTime]::Parse($chan.not_after, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) if ($notAfter -lt [DateTime]::UtcNow) { throw "install: channel.json EXPIRED: not_after=$($chan.not_after) now=$([DateTime]::UtcNow.ToString('o')) (operator must redeploy)" } # trusted_comment binding. $chanTrusted = (Get-Content -LiteralPath $chanSig | Where-Object { $_ -match '^trusted comment: ' } | Select-Object -First 1) -replace '^trusted comment: ', '' $expectedChanTrusted = "hoody-cdn bin version=$($chan.latest)" if ($chanTrusted -ne $expectedChanTrusted) { throw "install: channel.json trusted_comment mismatch: expected $expectedChanTrusted, got $chanTrusted (replay attack?)" } Write-Host "install: channel verified — version=$($chan.latest)" # --------------------------------------------------------------------- # Step 4: fetch + verify SHA256SUMS for the chosen version. # --------------------------------------------------------------------- $ver = $chan.latest $sumsPath = Join-Path $stageDir 'SHA256SUMS' $sumsSig = Join-Path $stageDir 'SHA256SUMS.minisig' Invoke-Fetch -Uri "https://$selectedHost/$ver/SHA256SUMS" -OutFile $sumsPath Invoke-Fetch -Uri "https://$selectedHost/$ver/SHA256SUMS.minisig" -OutFile $sumsSig $verify = & $verifierPath -V -p $pubPath -m $sumsPath -x $sumsSig 2>&1 if ($LASTEXITCODE -ne 0) { throw "install: $ver/SHA256SUMS.minisig FAILED verification. minisign output: $verify" } $sumsTrusted = (Get-Content -LiteralPath $sumsSig | Where-Object { $_ -match '^trusted comment: ' } | Select-Object -First 1) -replace '^trusted comment: ', '' $expectedSumsTrusted = "hoody-cdn bin version=$ver" if ($sumsTrusted -ne $expectedSumsTrusted) { throw "install: $ver/SHA256SUMS trusted_comment mismatch: expected $expectedSumsTrusted, got $sumsTrusted" } # Find the line for our archive. SHA256SUMS format: ` `. # Round 10 BLOCKER: .NET regex (used by PowerShell -match) does NOT # support PCRE `\Q…\E` — pre-fix every Windows install threw "no # entry" because the regex never matched. Use [regex]::Escape() for # the literal filename. $escapedArchive = [regex]::Escape($archiveName) $archivePat = "^([0-9a-f]{64})\s+$escapedArchive\s*$" $archivePinnedHash = $null foreach ($line in (Get-Content -LiteralPath $sumsPath)) { if ($line -match $archivePat) { $archivePinnedHash = $Matches[1] break } } if (-not $archivePinnedHash) { throw "install: SHA256SUMS for $ver has no entry for $archiveName" } # --------------------------------------------------------------------- # Step 5: download archive + verify hash. # --------------------------------------------------------------------- $archivePath = Join-Path $stageDir $archiveName Invoke-Fetch -Uri "https://$selectedHost/$ver/$archiveName" -OutFile $archivePath $gotArchiveHash = Get-FileSha256Lower -Path $archivePath if ($gotArchiveHash -ne $archivePinnedHash) { throw "install: archive hash mismatch for $archiveName: expected $archivePinnedHash, got $gotArchiveHash" } Write-Host "install: archive verified — $archiveName" # --------------------------------------------------------------------- # Step 6: extract zip into staged tree. # --------------------------------------------------------------------- $extractStage = Join-Path $stageDir 'extracted' $null = New-Item -ItemType Directory -Path $extractStage Expand-Archive -LiteralPath $archivePath -DestinationPath $extractStage -Force # Round 10 BLOCKER: reject reparse-points (junctions/symlinks) anywhere # in the extracted tree BEFORE the Test-Path-Leaf check. Test-Path # -PathType Leaf returns $true for reparse points, so a malicious zip # carrying hoody.exe as a junction → C:\Windows\System32\cmd.exe # would otherwise pass the Leaf test and Copy-Item would follow the # junction, copying the target into Programs\Hoody\hoody.exe. Walk # the staged tree and reject ANY reparse point. Get-ChildItem -LiteralPath $extractStage -Recurse -Force | ForEach-Object { if ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "install: extracted archive contains reparse-point ($($_.FullName)); refusing to install (corrupt build or compromised producer key?)" } } # Top-level binary. Belt-and-suspenders: also assert the top-level # 'hoody.exe' specifically is not a reparse point, even though the # walk above already covered it. $extractedBin = Join-Path $extractStage 'hoody.exe' if (-not (Test-Path -LiteralPath $extractedBin -PathType Leaf)) { throw "install: $archiveName did not contain 'hoody.exe' at top level" } $extractedBinItem = Get-Item -LiteralPath $extractedBin -Force if ($extractedBinItem.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "install: $archiveName top-level 'hoody.exe' is a reparse point; refusing to install" } # Round 10 MED: strip MOTW so SmartScreen / AV doesn't quarantine on # first run. The hash chain above is the integrity anchor — MOTW is # just UI friction. Unblock-File -LiteralPath $extractedBin -ErrorAction SilentlyContinue # --------------------------------------------------------------------- # Step 7: atomic move stage → %LOCALAPPDATA%\Programs\Hoody\versions\\. # Refuse to clobber a different-hash copy. # --------------------------------------------------------------------- $finalDir = Join-Path $versionsDir $ver if (Test-Path -LiteralPath $finalDir) { if (Test-Path -LiteralPath (Join-Path $finalDir 'hoody.exe') -PathType Leaf) { $existingHash = Get-FileSha256Lower -Path (Join-Path $finalDir 'hoody.exe') $freshHash = Get-FileSha256Lower -Path $extractedBin if ($existingHash -eq $freshHash) { Write-Host "install: version $ver already installed (hash matches); refreshing 'current' copy only" } else { throw "install: $finalDir\hoody.exe exists but hash differs from freshly-verified $ver. Remove $finalDir manually if intended." } } else { throw "install: $finalDir exists but contains no 'hoody.exe' (prior install corrupt); remove and retry" } } else { Move-Item -LiteralPath $extractStage -Destination $finalDir } # --------------------------------------------------------------------- # Step 8: update "current" copy at %LOCALAPPDATA%\Programs\Hoody\hoody.exe. # Windows symlinks require elevated privileges or Developer Mode; copy # is the most compatible cross-version approach. Atomic via Move-Item # of a sibling tmp file → final. # --------------------------------------------------------------------- $currentExe = Join-Path $installBase 'hoody.exe' $currentTmp = "$currentExe.new" # Round 10 HIGH: detect the "in-use" case (hoody.exe currently # running). MoveFileEx with REPLACE_EXISTING fails with # ERROR_ACCESS_DENIED when the target is a held exe; surface a clear # message rather than a generic Move-Item error. try { Copy-Item -LiteralPath (Join-Path $finalDir 'hoody.exe') -Destination $currentTmp -Force Move-Item -LiteralPath $currentTmp -Destination $currentExe -Force } catch { Remove-Item -LiteralPath $currentTmp -ErrorAction SilentlyContinue if ($_.Exception.HResult -eq -2147024864 -or "$($_.Exception.Message)" -match 'used by another process|access is denied') { throw "install: $currentExe is in use (hoody process running?); close all hoody.exe instances and re-run install" } throw } Unblock-File -LiteralPath $currentExe -ErrorAction SilentlyContinue Write-Host "install: hoody $ver installed at $finalDir\hoody.exe" Write-Host "install: 'current' copy at $currentExe" # PATH advice (we do NOT auto-edit user PATH — that's a foot-gun on # systems where PowerShell profile + cmd PATH diverge, and it's # invisible to the user. Tell them what to do). $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') if (-not $userPath -or $userPath -notmatch [regex]::Escape($installBase)) { Write-Host "install: NOTE: $installBase is not in your user PATH. Add it via:" Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$installBase;`$([Environment]::GetEnvironmentVariable('Path','User'))`", 'User')" Write-Host "Then open a new PowerShell window." } } finally { Cleanup-Install }