param( [ValidateSet("client")] [string]$Role = $(if ($env:DOSH_ROLE) { $env:DOSH_ROLE } else { "client" }), [string]$Repo = $(if ($env:DOSH_REPO) { $env:DOSH_REPO } else { "https://git.palav.dev/Palav/dosh.git" }), [string]$Server = $env:DOSH_SERVER, [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), [switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"), [string]$BinaryUrl = $env:DOSH_BINARY_URL, [string]$BinaryBase = $env:DOSH_BINARY_BASE, [string]$BinaryName = $env:DOSH_BINARY_NAME, [string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }), [string]$UpdateCache = $(if ($env:DOSH_UPDATE_CACHE) { $env:DOSH_UPDATE_CACHE } else { Join-Path $HOME ".cache\dosh\source" }), [switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"), [switch]$ForceConfig ) $ErrorActionPreference = "Stop" function Require-Command($Name) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "missing required command: $Name" } } function Normalize-Arch { switch ($env:PROCESSOR_ARCHITECTURE) { "AMD64" { "x86_64"; break } "ARM64" { "aarch64"; break } default { $env:PROCESSOR_ARCHITECTURE.ToLowerInvariant() } } } function Release-ArtifactName { if ($BinaryName) { return $BinaryName } "dosh-windows-$(Normalize-Arch).zip" } function Repo-WebBase($Value) { if (-not $Value) { return $null } $base = $Value.TrimEnd("/") if ($base.EndsWith(".git")) { $base = $base.Substring(0, $base.Length - 4) } if ($base -notmatch "^https?://") { return $null } $base } function Release-DownloadUrl { if ($BinaryUrl) { return $BinaryUrl } $name = Release-ArtifactName if ($BinaryBase) { return "$($BinaryBase.TrimEnd('/'))/$name" } $web = Repo-WebBase $Repo if (-not $web) { return $null } if ($BinaryVersion -eq "latest") { return "$web/releases/latest/download/$name" } "$web/releases/download/$BinaryVersion/$name" } function Release-LatestTagDownloadUrl { if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") { return $null } $web = Repo-WebBase $Repo if (-not $web) { return $null } try { $response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5 $effective = $null if ($response.BaseResponse.ResponseUri) { $effective = $response.BaseResponse.ResponseUri.AbsoluteUri } elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) { $effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri } $prefix = "$web/releases/tag/" if ($effective -and $effective.StartsWith($prefix)) { $tag = $effective.Substring($prefix.Length) if ($tag) { return "$web/releases/download/$tag/$(Release-ArtifactName)" } } } catch { return $null } return $null } function Version-Core($Value) { $base = $Value.TrimStart("v").Split("+")[0] $base.Split("-")[0] } function Version-Prerelease($Value) { $base = $Value.TrimStart("v").Split("+")[0] $dash = $base.IndexOf("-") if ($dash -lt 0) { return "" } $base.Substring($dash + 1).ToLowerInvariant() } function Version-Parts($Value) { [regex]::Matches((Version-Core $Value), "\d+") | ForEach-Object { [int64]$_.Value } } function Compare-Prerelease($Left, $Right) { if (-not $Left -and -not $Right) { return 0 } if (-not $Left) { return 1 } if (-not $Right) { return -1 } if ($Left -eq $Right) { return 0 } if ($Left -match "(?i)^rc(\d+)$" -and $Right -match "(?i)^rc(\d+)$") { $leftRc = [int64]([regex]::Match($Left, "(?i)^rc(\d+)$").Groups[1].Value) $rightRc = [int64]([regex]::Match($Right, "(?i)^rc(\d+)$").Groups[1].Value) return $leftRc.CompareTo($rightRc) } return [string]::CompareOrdinal($Left, $Right) } function Compare-DoshVersion($Left, $Right) { $leftParts = @(Version-Parts $Left) $rightParts = @(Version-Parts $Right) $width = [Math]::Max($leftParts.Count, $rightParts.Count) for ($i = 0; $i -lt $width; $i++) { $l = if ($i -lt $leftParts.Count) { $leftParts[$i] } else { 0 } $r = if ($i -lt $rightParts.Count) { $rightParts[$i] } else { 0 } if ($l -lt $r) { return -1 } if ($l -gt $r) { return 1 } } return Compare-Prerelease (Version-Prerelease $Left) (Version-Prerelease $Right) } function Current-SourceVersion { if (Test-Path "Cargo.toml") { $raw = Get-Content "Cargo.toml" -Raw } else { $web = Repo-WebBase $Repo if (-not $web) { return $null } try { $raw = (Invoke-WebRequest -UseBasicParsing -Uri "$web/raw/branch/main/Cargo.toml").Content } catch { return $null } } if ($raw -match '(?m)^version = "([^"]+)"') { return $Matches[1] } return $null } function Latest-ReleaseTag { $web = Repo-WebBase $Repo if (-not $web) { return $null } try { $response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5 $effective = $null if ($response.BaseResponse.ResponseUri) { $effective = $response.BaseResponse.ResponseUri.AbsoluteUri } elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) { $effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri } $prefix = "$web/releases/tag/" if ($effective -and $effective.StartsWith($prefix)) { return $effective.Substring($prefix.Length) } } catch { return $null } return $null } function Latest-ReleaseIsStale { if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") { return $false } $latest = Latest-ReleaseTag $current = Current-SourceVersion if (-not $latest -or -not $current) { return $false } $latestVersion = $latest.TrimStart("v") if ((Compare-DoshVersion $latestVersion $current) -lt 0) { Write-Warning "latest release $latestVersion is older than source $current; skipping stale prebuilt" return $true } return $false } function Verify-ArchiveChecksum($Url, $Archive) { $checksumPath = "$Archive.sha256" try { Invoke-WebRequest -UseBasicParsing -Uri "$Url.sha256" -OutFile $checksumPath } catch { throw "prebuilt checksum unavailable; refusing unverified binary for $Url" } $expected = ((Get-Content $checksumPath -Raw).Trim() -split "\s+")[0].ToLowerInvariant() $actual = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() if ($expected -ne $actual) { throw "prebuilt checksum mismatch for $Url" } } function Expected-ArchiveVersion($Url) { if ($Url -match "/releases/download/v([^/]+)/") { return $Matches[1] } if (-not $BinaryUrl -and -not $BinaryBase -and $BinaryVersion -ne "latest") { return $BinaryVersion.TrimStart("v") } return $null } function Verify-ArchiveVersion($ExtractDir, $Url) { $expected = Expected-ArchiveVersion $Url if (-not $expected) { return } $versionFile = Get-ChildItem -Path $ExtractDir -Recurse -File -Filter VERSION | Select-Object -First 1 if (-not $versionFile) { throw "prebuilt archive missing VERSION for $Url" } $actual = (Get-Content $versionFile.FullName -Raw).Trim() if ($actual -ne $expected) { throw "prebuilt archive version mismatch for ${Url}: expected $expected, got $actual" } } function Install-Binary($Source, $Destination) { $dir = Split-Path -Parent $Destination $name = Split-Path -Leaf $Destination $tmp = Join-Path $dir ".$name.tmp.$PID" Copy-Item $Source $tmp -Force try { Move-Item $tmp $Destination -Force } catch { Remove-Item $tmp -Force -ErrorAction SilentlyContinue throw } } function Normalize-PathForCompare($Path) { [System.IO.Path]::GetFullPath($Path).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) } function Assert-SafeUpdateCache($Path) { if (-not $Path) { throw "refusing unsafe update cache path: $Path" } $full = Normalize-PathForCompare $Path $root = Normalize-PathForCompare ([System.IO.Path]::GetPathRoot($full)) $homePath = Normalize-PathForCompare $HOME $homeCache = Normalize-PathForCompare (Join-Path $HOME ".cache") if ($full -eq $root -or $full -eq $homePath -or $full -eq $homeCache) { throw "refusing unsafe update cache path: $Path" } $full } $bindir = Join-Path $Prefix "bin" $configDir = Join-Path $HOME ".config\dosh" New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null function Install-Prebuilt { if (Latest-ReleaseIsStale) { return $false } $url = Release-LatestTagDownloadUrl if (-not $url) { $url = Release-DownloadUrl } if (-not $url) { return $false } $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid()) $zip = Join-Path $tmp (Release-ArtifactName) $extract = Join-Path $tmp "extract" try { New-Item -ItemType Directory -Force -Path $tmp, $extract | Out-Null Write-Host "Trying Dosh prebuilt $(Release-ArtifactName)" Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $zip Verify-ArchiveChecksum $url $zip Expand-Archive -Force -Path $zip -DestinationPath $extract Verify-ArchiveVersion $extract $url foreach ($bin in @("dosh-client.exe", "dosh-bench.exe")) { $found = Get-ChildItem -Path $extract -Recurse -File -Filter $bin | Select-Object -First 1 if (-not $found) { throw "prebuilt archive missing $bin" } Install-Binary $found.FullName (Join-Path $bindir $bin) } Install-Binary (Join-Path $bindir "dosh-client.exe") (Join-Path $bindir "dosh.exe") return $true } catch { Write-Warning "prebuilt install failed: $_" return $false } finally { if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp } } } function Install-FromSource { Require-Command cargo if (Test-Path "Cargo.toml") { $src = (Get-Location).Path } else { if (-not $Repo) { throw "DOSH_REPO is required when running the installer from irm/iex" } Require-Command git $sourceCache = Assert-SafeUpdateCache $UpdateCache $parent = Split-Path -Parent $sourceCache New-Item -ItemType Directory -Force -Path $parent | Out-Null if (Test-Path (Join-Path $sourceCache ".git")) { git -C $sourceCache remote set-url origin $Repo git -C $sourceCache fetch --depth 1 origin main git -C $sourceCache checkout -q -B main FETCH_HEAD } else { if (Test-Path $sourceCache) { Remove-Item -Recurse -Force $sourceCache } git clone --depth 1 --branch main $Repo $sourceCache | Out-Null } $src = $sourceCache } try { Push-Location $src cargo build --release --bin dosh-client --bin dosh-bench Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh-client.exe") Install-Binary "target\release\dosh-client.exe" (Join-Path $bindir "dosh.exe") Install-Binary "target\release\dosh-bench.exe" (Join-Path $bindir "dosh-bench.exe") } finally { Pop-Location } } if ($UsePrebuilt) { $ok = Install-Prebuilt if (-not $ok) { if ($BinaryRequired) { throw "prebuilt install failed and DOSH_BINARY_REQUIRED=1" } Write-Host "Falling back to source build" Install-FromSource } } else { Install-FromSource } $clientConfig = Join-Path $configDir "client.toml" if ($ForceConfig -or -not (Test-Path $clientConfig)) { $defaultServer = if ($Server) { $Server } else { "user@example.com" } $updateRepo = if ($Repo) { $Repo } else { "https://git.palav.dev/Palav/dosh.git" } $doshHostLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"public.example.com`"" } @" update_repo = "$updateRepo" update_port = $Port server = "$defaultServer" $doshHostLine ssh_auth_command = "~/.local/bin/dosh-auth" # ssh_port = 22 dosh_port = $Port default_session = "new" reconnect_timeout_secs = 5 view_only = false predict = true predict_mode = "experimental" cache_attach_tickets = true credential_cache = "~/.local/share/dosh/credentials" escape_key = "^]" "@ | Set-Content -NoNewline -Encoding utf8 $clientConfig } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if (-not (($userPath -split ';') -contains $bindir)) { [Environment]::SetEnvironmentVariable("Path", "$userPath;$bindir", "User") } Write-Host "Installed Dosh client to $bindir" Write-Host "Configured UDP port $Port" Write-Host "" $displayServer = if ($Server) { $Server } else { "user@host" } Write-Host "Client commands:" Write-Host " $bindir\dosh.exe $displayServer" Write-Host " $bindir\dosh.exe setup " Write-Host " $bindir\dosh.exe update --check" Write-Host "" Write-Host "Client config:" Write-Host " $configDir\client.toml" Write-Host "" Write-Host "Open a new terminal for PATH changes to apply."