aboutsummaryrefslogtreecommitdiff
path: root/website
diff options
context:
space:
mode:
Diffstat (limited to 'website')
-rw-r--r--website/install.ps1407
-rw-r--r--website/scripts/prepare-docs.mjs1
2 files changed, 408 insertions, 0 deletions
diff --git a/website/install.ps1 b/website/install.ps1
new file mode 100644
index 00000000..d07a5f1c
--- /dev/null
+++ b/website/install.ps1
@@ -0,0 +1,407 @@
+[CmdletBinding()]
+param(
+ [ValidateSet("stable", "preview")]
+ [string]$Channel = $env:HERDR_CHANNEL,
+ [string]$ManifestUrl = $env:HERDR_MANIFEST_URL,
+ [string]$InstallDir = $env:HERDR_INSTALL_DIR,
+ [int]$Retain = 3
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+$ProgressPreference = "SilentlyContinue"
+
+if ([string]::IsNullOrWhiteSpace($Channel)) {
+ $Channel = "stable"
+}
+
+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 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."
+ }
+
+ $asset = $property.Value
+ if ($asset -is [string]) {
+ return [PSCustomObject]@{
+ Url = $asset
+ Sha256 = $null
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace([string]$asset.url)) {
+ throw "Release manifest asset $Target is missing a URL."
+ }
+
+ return [PSCustomObject]@{
+ Url = [string]$asset.url
+ Sha256 = if ([string]::IsNullOrWhiteSpace([string]$asset.sha256)) { $null } else { [string]$asset.sha256 }
+ }
+}
+
+function ConvertTo-ManifestObject {
+ param([object]$Manifest)
+
+ if ($Manifest -isnot [string]) {
+ return $Manifest
+ }
+
+ $json = $Manifest.TrimStart([char]0xFEFF)
+ if ($json.StartsWith("")) {
+ $json = $json.Substring(3)
+ }
+
+ return $json | ConvertFrom-Json
+}
+
+function Test-FileDigest {
+ param(
+ [string]$Path,
+ [string]$ExpectedDigest
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ExpectedDigest)) {
+ return
+ }
+
+ $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actual -ne $ExpectedDigest.ToLowerInvariant()) {
+ throw "Downloaded Herdr checksum did not match. Expected $ExpectedDigest but got $actual."
+ }
+}
+
+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
+ )
+
+ 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 -Force
+ } elseif ($item.PSIsContainer) {
+ if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) {
+ throw "Refusing to replace non-empty directory at $LinkPath with a junction."
+ }
+ Remove-Item -LiteralPath $LinkPath -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 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.") } |
+ 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"
+ }
+ default {
+ Write-Error "Unsupported Windows architecture: $architecture"
+ exit 1
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($ManifestUrl)) {
+ $ManifestUrl = if ($Channel -eq "preview") {
+ "https://herdr.dev/preview.json"
+ } else {
+ "https://herdr.dev/latest.json"
+ }
+}
+
+$herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {
+ Join-Path $env:USERPROFILE ".herdr"
+} else {
+ $env:HERDR_HOME
+}
+$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
+}
+
+$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."
+}
+
+Write-Step "Fetching Herdr $Channel manifest"
+$manifest = ConvertTo-ManifestObject -Manifest (Invoke-RestMethod -Uri $ManifestUrl)
+$versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel
+$asset = Get-ManifestAsset -Manifest $manifest -Target $target
+$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-Path -LiteralPath (Join-Path $releaseDir "herdr.exe") -PathType Leaf)) {
+ if (Test-Path -LiteralPath $releaseDir) {
+ Remove-Item -LiteralPath $releaseDir -Recurse -Force
+ }
+
+ $downloadPath = Join-Path $tempDir "herdr.exe"
+ $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID"
+ Write-Step "Downloading Herdr"
+ Invoke-WebRequest -Uri $asset.Url -OutFile $downloadPath
+ Test-FileDigest -Path $downloadPath -ExpectedDigest $asset.Sha256
+
+ New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null
+ Copy-Item -LiteralPath $downloadPath -Destination (Join-Path $stagingDir "herdr.exe")
+ Move-Item -LiteralPath $stagingDir -Destination $releaseDir
+ }
+
+ Set-ManagedJunction -LinkPath $currentDir -TargetPath $releaseDir -ManagedTargetPrefix $releasesDir
+ Set-ManagedJunction -LinkPath $visibleBinDir -TargetPath $releaseDir -ManagedTargetPrefix $standaloneRoot
+
+ $herdrCommand = Join-Path $visibleBinDir "herdr.exe"
+ & $herdrCommand --version *> $null
+ if ($LASTEXITCODE -ne 0) {
+ throw "Installed Herdr command failed verification: $herdrCommand --version"
+ }
+
+ Remove-OldReleases -ReleasesDir $releasesDir -CurrentReleaseDir $releaseDir -Keep $Retain
+ }
+} finally {
+ Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+}
+
+$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
+$newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir
+if ($newUserPath -cne $userPath) {
+ [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
+ 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."
diff --git a/website/scripts/prepare-docs.mjs b/website/scripts/prepare-docs.mjs
index 19201a18..f7892d37 100644
--- a/website/scripts/prepare-docs.mjs
+++ b/website/scripts/prepare-docs.mjs
@@ -25,6 +25,7 @@ async function preparePublicAssets() {
for (const file of [
'install.sh',
+ 'install.ps1',
'latest.json',
'preview.json',
'robots.txt',