diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-09-01 14:48:45 +0300 |
| commit | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (patch) | |
| tree | ed7dc7e8c6f50d66bde721fae78576aede929f66 /distribution/install.ps1 | |
| parent | b54a78aa510b87d1e3b56c619b72eaeb59e4a2a1 (diff) | |
refactor: separate website presentation from runtime
Diffstat (limited to 'distribution/install.ps1')
| -rw-r--r-- | distribution/install.ps1 | 871 |
1 files changed, 871 insertions, 0 deletions
diff --git a/distribution/install.ps1 b/distribution/install.ps1 new file mode 100644 index 00000000..94a57314 --- /dev/null +++ b/distribution/install.ps1 @@ -0,0 +1,871 @@ +[CmdletBinding()] +param( + [string]$Channel = $env:HERDR_CHANNEL, + [string]$ManifestUrl = $env:HERDR_MANIFEST_URL, + [string]$InstallDir = $env:HERDR_INSTALL_DIR, + [string]$ExpectedBuildId = $env:HERDR_EXPECTED_BUILD_ID, + [int]$Retain = 3, + [string]$LocalPackagePath, + [string]$LocalPackageFormat, + [string]$LocalPackageIdentity, + [string]$LocalPackageSha256 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$channelWasExplicit = -not [string]::IsNullOrWhiteSpace($Channel) +if ($channelWasExplicit -and $Channel -notin @("stable", "preview")) { + Write-Error "Invalid Herdr channel '$Channel'. Use 'stable' or 'preview'." + exit 1 +} + +$localPackageValueCount = @( + $LocalPackagePath, + $LocalPackageFormat, + $LocalPackageIdentity, + $LocalPackageSha256 | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } +).Count +if ($localPackageValueCount -notin @(0, 4)) { + throw "Local package mode requires path, format, identity, and SHA-256." +} +$useLocalPackage = $localPackageValueCount -eq 4 +if ($useLocalPackage -and $LocalPackageFormat -notin @("zip", "exe")) { + throw "Local Herdr package has unsupported format '$LocalPackageFormat'." +} + +function Write-Step { + param([string]$Message) + Write-Host "==> $Message" +} + +function Write-WarningStep { + param([string]$Message) + Write-Warning $Message +} + +function Get-HerdrCommandSource { + $existing = Get-Command herdr -ErrorAction SilentlyContinue + if ($null -eq $existing) { + return $null + } + + return $existing.Source +} + +function Test-PathStartsWith { + param( + [string]$Path, + [string]$Prefix + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or [string]::IsNullOrWhiteSpace($Prefix)) { + return $false + } + + try { + $normalizedPath = [System.IO.Path]::GetFullPath($Path) + $normalizedPrefix = [System.IO.Path]::GetFullPath($Prefix).TrimEnd("\") + "\" + return $normalizedPath.StartsWith($normalizedPrefix, [System.StringComparison]::OrdinalIgnoreCase) + } catch { + return $false + } +} + +function Path-Contains { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $needle = $Entry.TrimEnd("\") + foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($segment.TrimEnd("\") -ieq $needle) { + return $true + } + } + + return $false +} + +function Prepend-PathEntry { + param( + [string]$PathValue, + [string]$Entry + ) + + $needle = $Entry.TrimEnd("\") + $segments = @($Entry) + if (-not [string]::IsNullOrWhiteSpace($PathValue)) { + $segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) | + Where-Object { $_.TrimEnd("\") -ine $needle } + } + + return ($segments -join ";") +} + +function Update-PathRegistryEntry { + param( + [Microsoft.Win32.RegistryKey]$EnvironmentKey, + [string]$Entry + ) + + $options = [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames + $value = $EnvironmentKey.GetValue("Path", $null, $options) + $kind = if ($null -eq $value) { + [Microsoft.Win32.RegistryValueKind]::String + } else { + $EnvironmentKey.GetValueKind("Path") + } + $newValue = Prepend-PathEntry -PathValue $value -Entry $Entry + if ($newValue -ceq $value) { + return $false + } + + $EnvironmentKey.SetValue("Path", $newValue, $kind) + return $true +} + +function Publish-EnvironmentChange { + if (-not ("HerdrInstaller.EnvironmentNativeMethods" -as [type])) { + Add-Type -Namespace HerdrInstaller -Name EnvironmentNativeMethods -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Unicode)] +public static extern System.IntPtr SendMessageTimeout( + System.IntPtr hWnd, + uint message, + System.UIntPtr wParam, + string lParam, + uint flags, + uint timeout, + out System.UIntPtr result); +'@ + } + + $result = [UIntPtr]::Zero + [HerdrInstaller.EnvironmentNativeMethods]::SendMessageTimeout( + [IntPtr]0xffff, + 0x1a, + [UIntPtr]::Zero, + "Environment", + 0x0002, + 1000, + [ref]$result + ) | Out-Null +} + +function Get-ManifestAsset { + param( + [object]$Manifest, + [string]$Target + ) + + $property = $Manifest.assets.PSObject.Properties[$Target] + if ($null -eq $property) { + throw "Release manifest does not include a binary for $Target." + } + + $sha256 = $null + $shaMapProperty = $Manifest.PSObject.Properties["sha256"] + if ($null -ne $shaMapProperty -and $null -ne $shaMapProperty.Value) { + $targetShaProperty = $shaMapProperty.Value.PSObject.Properties[$Target] + if ($null -ne $targetShaProperty -and -not [string]::IsNullOrWhiteSpace([string]$targetShaProperty.Value)) { + $sha256 = [string]$targetShaProperty.Value + } + } + + $asset = $property.Value + if ($asset -is [string]) { + $url = [string]$asset + return [PSCustomObject]@{ + Url = $url + Sha256 = $sha256 + Format = if ($url.EndsWith(".zip", [System.StringComparison]::OrdinalIgnoreCase)) { "zip" } else { "exe" } + } + } + + $urlProperty = $asset.PSObject.Properties["url"] + if ($null -eq $urlProperty -or [string]::IsNullOrWhiteSpace([string]$urlProperty.Value)) { + throw "Release manifest asset $Target is missing a URL." + } + + $url = [string]$urlProperty.Value + $formatProperty = $asset.PSObject.Properties["format"] + $format = if ($null -eq $formatProperty -or [string]::IsNullOrWhiteSpace([string]$formatProperty.Value)) { + if ($url.EndsWith(".zip", [System.StringComparison]::OrdinalIgnoreCase)) { "zip" } else { "exe" } + } else { + [string]$formatProperty.Value + } + if ($format -notin @("zip", "exe")) { + throw "Release manifest asset $Target has unsupported format '$format'." + } + $shaProperty = $asset.PSObject.Properties["sha256"] + if ($null -ne $shaProperty -and -not [string]::IsNullOrWhiteSpace([string]$shaProperty.Value)) { + $sha256 = [string]$shaProperty.Value + } + + return [PSCustomObject]@{ + Url = $url + Sha256 = $sha256 + Format = $format + } +} + +function Invoke-CurlDownload { + param( + [string]$Uri, + [string]$Destination + ) + + $parsedUri = $null + if (-not [System.Uri]::TryCreate($Uri, [System.UriKind]::Absolute, [ref]$parsedUri) -or + $parsedUri.Scheme -notin @("http", "https")) { + throw "Herdr download URL must use HTTP or HTTPS: $Uri" + } + + $curl = Get-Command curl.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -eq $curl) { + throw "Herdr installation requires curl.exe, which is included with supported Windows versions." + } + + $arguments = @( + "--fail", + "--silent", + "--show-error", + "--location", + "--connect-timeout", "30", + "--speed-limit", "1024", + "--speed-time", "30" + ) + if ($parsedUri.Scheme -eq "https") { + $arguments += @("--proto", "=https", "--tlsv1.2") + } + $arguments += @("--output", $Destination, "--", $Uri) + + $curlOutput = & $curl.Source @arguments 2>&1 + $curlExitCode = $LASTEXITCODE + if ($curlExitCode -ne 0) { + $detail = ($curlOutput | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine + $message = "Failed to download $Uri (curl exit code $curlExitCode)." + if (-not [string]::IsNullOrWhiteSpace($detail)) { + $message += " $detail" + } + throw $message + } +} + +function ConvertTo-ManifestObject { + param([object]$Manifest) + + if ($Manifest -isnot [string]) { + return $Manifest + } + + $json = $Manifest.TrimStart([char]0xFEFF) + $utf8BomDecodedAsLatin1 = [string]::Concat([char]0x00EF, [char]0x00BB, [char]0x00BF) + if ($json.StartsWith($utf8BomDecodedAsLatin1)) { + $json = $json.Substring(3) + } + + return $json | ConvertFrom-Json +} + +function Get-RemoteManifest { + param([string]$Uri) + + $manifestPath = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-manifest-" + [System.Guid]::NewGuid().ToString("N") + ".json") + try { + Invoke-CurlDownload -Uri $Uri -Destination $manifestPath + return ConvertTo-ManifestObject -Manifest ([System.IO.File]::ReadAllText($manifestPath)) + } finally { + Remove-Item -LiteralPath $manifestPath -Force -ErrorAction SilentlyContinue + } +} + +function Test-FileDigest { + param( + [string]$Path, + [string]$ExpectedDigest + ) + + if ([string]::IsNullOrWhiteSpace($ExpectedDigest)) { + throw "A SHA-256 checksum is required for $Path." + } + if ($ExpectedDigest -notmatch '^[0-9a-fA-F]{64}$') { + throw "Invalid SHA-256 checksum for $Path." + } + + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.IO.File]::ReadAllBytes($Path) + $actual = [System.BitConverter]::ToString($sha256.ComputeHash($bytes)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } + if ($actual -ne $ExpectedDigest.ToLowerInvariant()) { + throw "Downloaded Herdr checksum did not match. Expected $ExpectedDigest but got $actual." + } +} + +function Test-RegularFile { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + return -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Test-RegularDirectory { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + return -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Test-HerdrReleaseComplete { + param( + [string]$ReleaseDir, + [string]$Format + ) + + if (-not (Test-RegularDirectory -Path $ReleaseDir)) { + return $false + } + $herdrExe = Join-Path $ReleaseDir "herdr.exe" + if (-not (Test-RegularFile -Path $herdrExe)) { + return $false + } + if ($Format -eq "exe") { + return $true + } + + $conptyRoot = Join-Path $ReleaseDir "conpty" + if (-not (Test-RegularDirectory -Path $conptyRoot) -or + -not (Test-RegularDirectory -Path (Join-Path $conptyRoot "x64")) -or + -not (Test-RegularDirectory -Path (Join-Path $conptyRoot "arm64"))) { + return $false + } + $markerPath = Join-Path $conptyRoot "herdr-conpty.json" + $required = @( + "conpty/conpty.dll", + "conpty/x64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe", + "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-LICENSE.txt", + "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-NOTICE.md" + ) + foreach ($relative in $required) { + if (-not (Test-RegularFile -Path (Join-Path $ReleaseDir ($relative -replace '/', '\')))) { + return $false + } + } + if (-not (Test-RegularFile -Path $markerPath)) { + return $false + } + + try { + $marker = ConvertTo-ManifestObject -Manifest (Get-Content -LiteralPath $markerPath -Raw) + $schemaProperty = $marker.PSObject.Properties["schema_version"] + $packageProperty = $marker.PSObject.Properties["package"] + $versionProperty = $marker.PSObject.Properties["version"] + $architectureProperty = $marker.PSObject.Properties["architecture"] + $filesProperty = $marker.PSObject.Properties["files"] + if ($null -eq $schemaProperty -or [int]$schemaProperty.Value -ne 1 -or + $null -eq $packageProperty -or [string]$packageProperty.Value -ne "Microsoft.Windows.Console.ConPTY" -or + $null -eq $versionProperty -or [string]::IsNullOrWhiteSpace([string]$versionProperty.Value) -or + $null -eq $architectureProperty -or [string]$architectureProperty.Value -ne "x86_64" -or + $null -eq $filesProperty) { + return $false + } + + $expectedConptyFiles = @( + "conpty/conpty.dll", + "conpty/x64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe" + ) + $markerFileNames = @($filesProperty.Value.PSObject.Properties | ForEach-Object { $_.Name }) + if (@(Compare-Object $expectedConptyFiles $markerFileNames).Count -ne 0) { + return $false + } + + $bundleEntries = @(Get-ChildItem -LiteralPath $conptyRoot -Force -Recurse) + if (@($bundleEntries | Where-Object { + $_.Attributes -band [IO.FileAttributes]::ReparsePoint + }).Count -ne 0) { + return $false + } + $releaseRoot = [System.IO.Path]::GetFullPath($ReleaseDir).TrimEnd('\') + $actualBundleFiles = @($bundleEntries | Where-Object { -not $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($releaseRoot.Length + 1).Replace('\', '/') + }) + $expectedBundleFiles = @($expectedConptyFiles) + "conpty/herdr-conpty.json" + if (@(Compare-Object $expectedBundleFiles $actualBundleFiles).Count -ne 0) { + return $false + } + foreach ($relative in $expectedConptyFiles) { + $digestProperty = $filesProperty.Value.PSObject.Properties[$relative] + if ($null -eq $digestProperty) { + return $false + } + Test-FileDigest -Path (Join-Path $ReleaseDir ($relative -replace '/', '\')) -ExpectedDigest ([string]$digestProperty.Value) + } + } catch { + return $false + } + return $true +} + +function Move-DirectoryWithRetry { + param( + [string]$Source, + [string]$Destination, + [int]$TimeoutMilliseconds = 5000 + ) + + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while ($true) { + try { + [System.IO.Directory]::Move($Source, $Destination) + return + } catch { + $retryable = $false + $exception = $_.Exception + while ($null -ne $exception) { + if ($exception -is [System.IO.IOException] -or + $exception -is [System.UnauthorizedAccessException]) { + $retryable = $true + break + } + $exception = $exception.InnerException + } + if (-not $retryable -or + [DateTime]::UtcNow -ge $deadline -or + -not (Test-Path -LiteralPath $Source -PathType Container) -or + (Test-Path -LiteralPath $Destination)) { + throw + } + Start-Sleep -Milliseconds 100 + } + } +} + +function Remove-DirectoryWithRetry { + param( + [string]$Path, + [int]$TimeoutMilliseconds = 5000 + ) + + $fullPath = [System.IO.Path]::GetFullPath($Path) + $extendedPath = if ($fullPath.StartsWith("\\")) { + "\\?\UNC\" + $fullPath.TrimStart([char]'\') + } else { + "\\?\" + $fullPath + } + $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) + while (Test-Path -LiteralPath $Path) { + try { + [System.IO.Directory]::Delete($extendedPath, $true) + return + } catch { + if ([DateTime]::UtcNow -ge $deadline) { + Write-WarningStep "Herdr installed successfully but could not remove a temporary release backup at $Path." + return + } + Start-Sleep -Milliseconds 100 + } + } +} + +function Invoke-WithInstallLock { + param( + [string]$LockPath, + [scriptblock]$Script + ) + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LockPath) | Out-Null + $lock = $null + while ($null -eq $lock) { + try { + $lock = [System.IO.File]::Open( + $LockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch [System.IO.IOException] { + Start-Sleep -Milliseconds 250 + } + } + + try { + & $Script + } finally { + $lock.Dispose() + } +} + +function Test-IsJunction { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false + } + + $item = Get-Item -LiteralPath $Path -Force + return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction" +} + +function Set-ManagedJunction { + param( + [string]$LinkPath, + [string]$TargetPath, + [string]$ManagedTargetPrefix, + [bool]$AllowLegacyHerdrBinMigration = $false + ) + + if (Test-Path -LiteralPath $LinkPath) { + $item = Get-Item -LiteralPath $LinkPath -Force + if (Test-IsJunction -Path $LinkPath) { + $existingTarget = [string]$item.Target + if (-not [string]::IsNullOrWhiteSpace($ManagedTargetPrefix)) { + $ownedPrefix = $ManagedTargetPrefix.TrimEnd("\") + if (-not $existingTarget.StartsWith($ownedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer." + } + } + if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + Remove-Item -LiteralPath $LinkPath -Recurse -Force + } elseif ($item.PSIsContainer) { + if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { + if (-not (Move-LegacyHerdrBinDirectory -Path $LinkPath -AllowMigration $AllowLegacyHerdrBinMigration)) { + throw "Refusing to replace non-empty directory at $LinkPath with a junction." + } + } else { + Remove-Item -LiteralPath $LinkPath -Recurse -Force + } + } else { + throw "Refusing to replace file at $LinkPath with a junction." + } + } + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LinkPath) | Out-Null + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null +} + +function Move-LegacyHerdrBinDirectory { + param( + [string]$Path, + [bool]$AllowMigration + ) + + if (-not $AllowMigration) { + return $false + } + + $entries = @(Get-ChildItem -LiteralPath $Path -Force) + if (($entries | Where-Object { $_.PSIsContainer } | Select-Object -First 1) -ne $null) { + return $false + } + + if (($entries | Where-Object { $_.Name -ieq "herdr.exe" } | Select-Object -First 1) -eq $null) { + return $false + } + + $legacyPath = "$Path.legacy.$([System.Guid]::NewGuid().ToString("N"))" + Move-Item -LiteralPath $Path -Destination $legacyPath + Write-Step "Moved legacy Herdr bin directory to $legacyPath." + return $true +} + +function Remove-StaleInstallArtifacts { + param([string]$ReleasesDir) + + if (-not (Test-Path -LiteralPath $ReleasesDir -PathType Container)) { + return + } + + Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} + +function Remove-OldReleases { + param( + [string]$ReleasesDir, + [string]$CurrentReleaseDir, + [int]$Keep + ) + + if ($Keep -lt 1 -or -not (Test-Path -LiteralPath $ReleasesDir -PathType Container)) { + return + } + + $currentFullPath = [System.IO.Path]::GetFullPath($CurrentReleaseDir) + $releaseDirs = Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -ErrorAction SilentlyContinue | + Where-Object { -not $_.Name.StartsWith(".staging.") -and -not $_.Name.StartsWith(".backup.") } | + Sort-Object LastWriteTimeUtc -Descending + $kept = 0 + foreach ($dir in $releaseDirs) { + $dirFullPath = [System.IO.Path]::GetFullPath($dir.FullName) + if ($dirFullPath.Equals($currentFullPath, [System.StringComparison]::OrdinalIgnoreCase)) { + $kept += 1 + continue + } + if ($kept -lt $Keep) { + $kept += 1 + continue + } + Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Resolve-HerdrVersion { + param( + [object]$Manifest, + [string]$SelectedChannel + ) + + if ($SelectedChannel -eq "preview") { + if ([string]::IsNullOrWhiteSpace([string]$Manifest.base_version) -or [string]::IsNullOrWhiteSpace([string]$Manifest.build_id)) { + throw "Preview manifest is missing base_version or build_id." + } + return "$($Manifest.base_version)-preview.$($Manifest.build_id)" + } + + if ([string]::IsNullOrWhiteSpace([string]$Manifest.version)) { + throw "Stable manifest is missing version." + } + return [string]$Manifest.version +} + +if ($env:OS -ne "Windows_NT") { + Write-Error "install.ps1 supports Windows only. Use install.sh on Linux or macOS." + exit 1 +} + +if (-not [Environment]::Is64BitOperatingSystem) { + Write-Error "Herdr requires 64-bit Windows." + exit 1 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation,mscorlib]::OSArchitecture.ToString() +switch ($architecture) { + "X64" { + $target = "windows-x86_64" + $targetTriple = "x86_64-pc-windows-msvc" + } + "Arm64" { + $target = "windows-x86_64" + $targetTriple = "x86_64-pc-windows-msvc" + Write-Step "Windows ARM64 detected; installing the x86_64 build under Windows emulation." + } + default { + Write-Error "Unsupported Windows architecture: $architecture" + exit 1 + } +} + +$herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) { + Join-Path $env:USERPROFILE ".herdr" +} else { + $env:HERDR_HOME +} +$herdrHome = [System.IO.Path]::GetFullPath($herdrHome) +$standaloneRoot = Join-Path $herdrHome "packages\standalone" +$releasesDir = Join-Path $standaloneRoot "releases" +$currentDir = Join-Path $standaloneRoot "current" +$lockPath = Join-Path $standaloneRoot "install.lock" + +$defaultVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\Herdr\bin" +$visibleBinDir = if ([string]::IsNullOrWhiteSpace($InstallDir)) { + $defaultVisibleBinDir +} else { + $InstallDir +} +$allowLegacyVisibleBinMigration = $false +try { + $allowLegacyVisibleBinMigration = [System.IO.Path]::GetFullPath($visibleBinDir).TrimEnd("\").Equals( + [System.IO.Path]::GetFullPath($defaultVisibleBinDir).TrimEnd("\"), + [System.StringComparison]::OrdinalIgnoreCase + ) +} catch { + $allowLegacyVisibleBinMigration = $false +} + +$existingHerdr = Get-HerdrCommandSource +if (-not [string]::IsNullOrWhiteSpace($existingHerdr) -and -not (Test-PathStartsWith -Path $existingHerdr -Prefix $visibleBinDir)) { + Write-Step "Detected existing Herdr command at $existingHerdr" + Write-WarningStep "PATH order decides which Herdr runs. This installer will put $visibleBinDir first for future and current PowerShell sessions." +} + +if ($useLocalPackage) { + $versionIdentity = $LocalPackageIdentity + $asset = [PSCustomObject]@{ + Sha256 = $LocalPackageSha256 + Format = $LocalPackageFormat + } +} else { + if (-not $channelWasExplicit) { + if (-not [string]::IsNullOrWhiteSpace($existingHerdr)) { + $detectedChannel = [string](& $existingHerdr channel show 2>$null | Select-Object -Last 1) + $detectedChannel = $detectedChannel.Trim() + if ($LASTEXITCODE -ne 0 -or $detectedChannel -notin @("stable", "preview")) { + throw "Could not determine the existing Herdr update channel. Rerun with -Channel stable or -Channel preview." + } + $Channel = $detectedChannel + Write-Step "Preserving existing Herdr $Channel channel" + } elseif (-not [string]::IsNullOrWhiteSpace($ManifestUrl) -and $ManifestUrl -match "/preview\.json$") { + $Channel = "preview" + } else { + $Channel = "stable" + } + } + + if ([string]::IsNullOrWhiteSpace($ManifestUrl)) { + $ManifestUrl = if ($Channel -eq "preview") { + "https://herdr.dev/preview.json" + } else { + "https://herdr.dev/latest.json" + } + } + + Write-Step "Fetching Herdr $Channel manifest" + $manifest = Get-RemoteManifest -Uri $ManifestUrl + $manifestChannelProperty = $manifest.PSObject.Properties["channel"] + if (-not $channelWasExplicit -and $null -ne $manifestChannelProperty -and [string]$manifestChannelProperty.Value -eq "preview") { + $Channel = "preview" + } + $assetsProperty = $manifest.PSObject.Properties["assets"] + $assetProperty = if ($null -eq $assetsProperty) { + $null + } else { + $assetsProperty.Value.PSObject.Properties[$target] + } + if ($null -eq $assetProperty -and + -not $channelWasExplicit -and + $Channel -eq "stable" -and + $ManifestUrl -match "/latest\.json$") { + Write-WarningStep "The stable manifest does not include Windows yet; using preview during the stable-channel rollout." + $Channel = "preview" + $ManifestUrl = $ManifestUrl.Substring(0, $ManifestUrl.Length - "latest.json".Length) + "preview.json" + Write-Step "Fetching Herdr preview manifest" + $manifest = Get-RemoteManifest -Uri $ManifestUrl + } + $asset = Get-ManifestAsset -Manifest $manifest -Target $target + if (-not [string]::IsNullOrWhiteSpace($ExpectedBuildId) -and [string]$manifest.build_id -ne $ExpectedBuildId) { + throw "Preview manifest changed while updating. Expected build $ExpectedBuildId but found $($manifest.build_id). Run herdr update again." + } + $versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel +} +$safeVersionIdentity = $versionIdentity -replace '[^0-9A-Za-z._-]', '-' +$releaseName = "$safeVersionIdentity-$targetTriple" +$releaseDir = Join-Path $releasesDir $releaseName + +Write-Step "Installing Herdr $versionIdentity for $targetTriple" +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("herdr-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + +try { + Invoke-WithInstallLock -LockPath $lockPath -Script { + Remove-StaleInstallArtifacts -ReleasesDir $releasesDir + + if (-not (Test-HerdrReleaseComplete -ReleaseDir $releaseDir -Format $asset.Format)) { + $downloadPath = if ($useLocalPackage) { + $LocalPackagePath + } else { + Join-Path $tempDir "herdr-download.$($asset.Format)" + } + $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" + if (-not $useLocalPackage) { + Write-Step "Downloading Herdr" + Invoke-CurlDownload -Uri $asset.Url -Destination $downloadPath + } + Test-FileDigest -Path $downloadPath -ExpectedDigest $asset.Sha256 + + if ($asset.Format -eq "zip") { + Expand-Archive -LiteralPath $downloadPath -DestinationPath $stagingDir + } else { + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + Copy-Item -LiteralPath $downloadPath -Destination (Join-Path $stagingDir "herdr.exe") + } + if (-not (Test-HerdrReleaseComplete -ReleaseDir $stagingDir -Format $asset.Format)) { + throw "Downloaded Herdr package is incomplete or failed ConPTY verification." + } + $stagedHerdr = Join-Path $stagingDir "herdr.exe" + & $stagedHerdr --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Downloaded Herdr command failed verification: $stagedHerdr --version" + } + $backupDir = $null + if (Test-Path -LiteralPath $releaseDir) { + $backupDir = Join-Path $releasesDir ".backup.$releaseName.$([System.Guid]::NewGuid().ToString('N'))" + [System.IO.Directory]::Move($releaseDir, $backupDir) + } + try { + Move-DirectoryWithRetry -Source $stagingDir -Destination $releaseDir + } catch { + if ($null -ne $backupDir -and -not (Test-Path -LiteralPath $releaseDir)) { + [System.IO.Directory]::Move($backupDir, $releaseDir) + } + Write-WarningStep "Windows could not activate the downloaded release. Another process may have a package file open, such as antivirus or indexing. No incomplete release was activated. Run herdr update again." + throw + } + } + + $releaseHerdr = Join-Path $releaseDir "herdr.exe" + & $releaseHerdr --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Installed Herdr command failed verification: $releaseHerdr --version" + } + Get-ChildItem -LiteralPath $releasesDir -Force -Directory -Filter ".backup.$releaseName.*" -ErrorAction SilentlyContinue | + ForEach-Object { Remove-DirectoryWithRetry -Path $_.FullName } + + Set-ManagedJunction -LinkPath $currentDir -TargetPath $releaseDir -ManagedTargetPrefix $releasesDir + Set-ManagedJunction -LinkPath $visibleBinDir -TargetPath $releaseDir -ManagedTargetPrefix $standaloneRoot -AllowLegacyHerdrBinMigration $allowLegacyVisibleBinMigration + + Remove-OldReleases -ReleasesDir $releasesDir -CurrentReleaseDir $releaseDir -Keep $Retain + } +} finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue +} + +$userEnvironmentKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") +if ($null -eq $userEnvironmentKey) { + throw "Unable to open the current user's environment registry key." +} +try { + $userPathChanged = Update-PathRegistryEntry -EnvironmentKey $userEnvironmentKey -Entry $visibleBinDir +} finally { + $userEnvironmentKey.Dispose() +} +if ($userPathChanged) { + Publish-EnvironmentChange + Write-Step "PATH updated for future PowerShell sessions." +} else { + Write-Step "$visibleBinDir is already first on PATH." +} + +$newProcessPath = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir +if ($newProcessPath -cne $env:Path) { + $env:Path = $newProcessPath +} + +$resolvedHerdr = Get-HerdrCommandSource +if (-not (Test-PathStartsWith -Path $resolvedHerdr -Prefix $visibleBinDir)) { + Write-WarningStep "PowerShell still resolves herdr to $resolvedHerdr. Open a new PowerShell window or inspect PATH order manually." +} + +Write-Step "Current PowerShell session: herdr" +Write-Step "Future PowerShell windows: open a new PowerShell window and run: herdr" +Write-Host "Herdr $versionIdentity installed successfully." |
