[CmdletBinding()] param( [string]$Version = $env:KLINK_VERSION, [string]$InstallDir = $env:KLINK_INSTALL_DIR ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" if ($PSVersionTable.PSEdition -eq "Desktop") { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } $RepositoryUrl = if ($env:KLINK_REPOSITORY_URL) { $env:KLINK_REPOSITORY_URL.TrimEnd("/") } else { "https://gitea.bytevibe.dev/gary/klink" } $ApiUrl = if ($env:KLINK_API_URL) { $env:KLINK_API_URL } else { "https://gitea.bytevibe.dev/api/v1/repos/gary/klink/releases/latest" } $AllowInsecure = $env:KLINK_ALLOW_INSECURE -eq "1" function Assert-SecureUrl([string]$Url) { if (-not $Url.StartsWith("https://", [System.StringComparison]::OrdinalIgnoreCase) -and -not $AllowInsecure) { throw "Refusing non-HTTPS URL: $Url" } } function Download-File([string]$Url, [string]$Destination) { Assert-SecureUrl $Url Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination } function Get-KlinkArchitecture { if (-not [string]::IsNullOrWhiteSpace($env:KLINK_ARCH)) { return $env:KLINK_ARCH.ToLowerInvariant() } try { $RuntimeInformationType = [System.Runtime.InteropServices.RuntimeInformation] $OSArchitectureProperty = $RuntimeInformationType.GetProperty("OSArchitecture") if ($null -ne $OSArchitectureProperty) { $RuntimeArchitecture = $OSArchitectureProperty.GetValue($null, $null) if ($null -ne $RuntimeArchitecture) { return $RuntimeArchitecture.ToString().ToLowerInvariant() } } } catch { # Windows PowerShell 5.1 can run on .NET versions without RuntimeInformation. } foreach ($EnvironmentArchitecture in @($env:PROCESSOR_ARCHITEW6432, $env:PROCESSOR_ARCHITECTURE)) { if (-not [string]::IsNullOrWhiteSpace($EnvironmentArchitecture)) { return $EnvironmentArchitecture.ToLowerInvariant() } } throw "Could not detect the Windows architecture; set KLINK_ARCH to amd64 or arm64" } if ($env:KLINK_OS -and $env:KLINK_OS -ne "windows") { throw "install.ps1 only installs Windows releases" } $Architecture = Get-KlinkArchitecture switch ($Architecture) { { $_ -in @("x64", "amd64") } { $TargetArch = "amd64"; break } { $_ -in @("arm64", "aarch64") } { $TargetArch = "arm64"; break } default { throw "Unsupported Windows architecture: $Architecture" } } $Release = $null if ([string]::IsNullOrWhiteSpace($Version)) { Assert-SecureUrl $ApiUrl $Release = Invoke-RestMethod -Uri $ApiUrl $Tag = [string]$Release.tag_name if ([string]::IsNullOrWhiteSpace($Tag)) { throw "Latest Gitea release has no tag" } } else { $Tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" } } $ReleaseVersion = $Tag.TrimStart("v") $AssetName = "klink_${ReleaseVersion}_windows_${TargetArch}.zip" if ($null -ne $Release) { $ArchiveAsset = @($Release.assets | Where-Object { $_.name -eq $AssetName }) | Select-Object -First 1 $ChecksumAsset = @($Release.assets | Where-Object { $_.name -eq "checksums.txt" }) | Select-Object -First 1 if ($null -eq $ArchiveAsset) { throw "Release $Tag has no asset named $AssetName" } if ($null -eq $ChecksumAsset) { throw "Release $Tag has no checksums.txt" } $ArchiveUrl = [string]$ArchiveAsset.browser_download_url $ChecksumUrl = [string]$ChecksumAsset.browser_download_url } else { $DownloadBase = "$RepositoryUrl/releases/download/$Tag" $ArchiveUrl = "$DownloadBase/$AssetName" $ChecksumUrl = "$DownloadBase/checksums.txt" } $Temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("klink-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $Temporary | Out-Null try { $Archive = Join-Path $Temporary $AssetName $Checksums = Join-Path $Temporary "checksums.txt" Write-Host "Downloading klink $Tag for windows/$TargetArch..." Download-File $ArchiveUrl $Archive Download-File $ChecksumUrl $Checksums $Expected = $null foreach ($Line in Get-Content -LiteralPath $Checksums) { if ($Line -match '^\s*([0-9a-fA-F]{64})\s+\*?(.+?)\s*$' -and $Matches[2] -eq $AssetName) { $Expected = $Matches[1].ToLowerInvariant() break } } if ($null -eq $Expected) { throw "$AssetName is not listed in checksums.txt" } $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Archive).Hash.ToLowerInvariant() if ($Actual -ne $Expected) { throw "SHA256 checksum mismatch for $AssetName" } $ExtractDir = Join-Path $Temporary "extract" Expand-Archive -LiteralPath $Archive -DestinationPath $ExtractDir $Binary = Get-ChildItem -LiteralPath $ExtractDir -Filter "klink.exe" -File -Recurse | Select-Object -First 1 if ($null -eq $Binary) { throw "Release archive does not contain klink.exe" } if ([string]::IsNullOrWhiteSpace($InstallDir)) { $InstallDir = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Programs\klink\bin" } else { Join-Path $HOME ".local\bin" } } New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $Destination = Join-Path $InstallDir "klink.exe" $Staged = Join-Path $InstallDir (".klink-install-" + $PID + ".exe") Copy-Item -LiteralPath $Binary.FullName -Destination $Staged -Force Move-Item -LiteralPath $Staged -Destination $Destination -Force $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") $PathEntries = @($UserPath -split ";" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if (-not ($PathEntries | Where-Object { $_.TrimEnd("\") -ieq $InstallDir.TrimEnd("\") })) { $NewUserPath = (@($PathEntries) + $InstallDir) -join ";" [Environment]::SetEnvironmentVariable("Path", $NewUserPath, "User") Write-Host "Added $InstallDir to your user PATH. Open a new terminal to use it." } if (-not (($env:Path -split ";") -contains $InstallDir)) { $env:Path = "$InstallDir;$env:Path" } Write-Host "Installed klink $Tag to $Destination" if (-not (Get-Command ssh.exe -ErrorAction SilentlyContinue)) { Write-Warning "OpenSSH was not found in PATH; install the Windows OpenSSH Client before connecting to nodes." } } finally { Remove-Item -LiteralPath $Temporary -Recurse -Force -ErrorAction SilentlyContinue }