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" }), [switch]$BinaryExact = $($env:DOSH_BINARY_EXACT -and $env:DOSH_BINARY_EXACT -ne "0"), [string]$UpdateCache = $(if ($env:DOSH_UPDATE_CACHE) { $env:DOSH_UPDATE_CACHE } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "dosh\source" } else { Join-Path $HOME ".cache\dosh\source" }), [switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"), [switch]$FromCurrent, [switch]$ForceConfig ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" $Quiet = $env:DOSH_UPDATE_QUIET -and $env:DOSH_UPDATE_QUIET -ne "0" $script:ResolvedSourceRef = $null function Write-Info($Message) { if (-not $Quiet) { Write-Host $Message } } function Require-Command($Name) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "missing required command: $Name" } } function Ensure-Cargo { if (Get-Command cargo -ErrorAction SilentlyContinue) { return } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { throw "cargo not found and winget is unavailable; install Rust from https://rustup.rs or set DOSH_USE_PREBUILT=1" } Write-Info "cargo not found; installing Rust toolchain with winget/rustup" winget install --id Rustlang.Rustup -e --accept-package-agreements --accept-source-agreements $cargoBin = Join-Path $HOME ".cargo\bin" if ((Test-Path $cargoBin) -and -not (PathList-Contains $env:Path $cargoBin)) { $env:Path = "$cargoBin;$env:Path" } if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { throw "cargo was installed but is not available in this terminal yet; open a new terminal and rerun dosh update" } } function Ensure-Git { if (Get-Command git -ErrorAction SilentlyContinue) { return } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { throw "git not found and winget is unavailable; install Git from https://git-scm.com/download/win or set DOSH_USE_PREBUILT=1" } Write-Info "git not found; installing Git with winget" winget install --id Git.Git -e --accept-package-agreements --accept-source-agreements $gitBins = @() if ($env:ProgramFiles) { $gitBins += (Join-Path $env:ProgramFiles "Git\cmd") } if (${env:ProgramFiles(x86)}) { $gitBins += (Join-Path ${env:ProgramFiles(x86)} "Git\cmd") } if ($env:LOCALAPPDATA) { $gitBins += (Join-Path $env:LOCALAPPDATA "Programs\Git\cmd") } foreach ($gitBin in $gitBins) { if ((Test-Path $gitBin) -and -not (PathList-Contains $env:Path $gitBin)) { $env:Path = "$gitBin;$env:Path" } } if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw "git was installed but is not available in this terminal yet; open a new terminal and rerun dosh update" } } function Normalize-Arch { $arch = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } switch ($arch) { "AMD64" { "x86_64"; break } "ARM64" { "aarch64"; break } default { if ($arch) { $arch.ToLowerInvariant() } else { "unknown" } } } } 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 } $releaseRef = Source-ReleaseRef if (-not $releaseRef) { return $null } "$web/releases/download/$releaseRef/$name" } function Current-SourceVersion { if ($FromCurrent) { if (-not (Test-Path "Cargo.toml")) { return $null } $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 Source-ReleaseRef { if ($BinaryUrl -or $BinaryBase) { return $null } if ($BinaryVersion -ne "latest" -and $BinaryExact) { return $BinaryVersion } $current = Current-SourceVersion if ($current) { return "v$($current.TrimStart('v'))" } if ($BinaryVersion -ne "latest") { return $BinaryVersion } return $null } 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 -LiteralPath $Source -Destination $tmp -Force try { Move-Item -LiteralPath $tmp -Destination $Destination -Force } catch { $pending = "$Destination.pending.$PID" try { Move-Item -LiteralPath $tmp -Destination $pending -Force Start-DeferredBinaryReplacement $pending $Destination Write-Warning "binary is in use; staged replacement for when Dosh exits: $Destination" } catch { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $pending -Force -ErrorAction SilentlyContinue throw } } } function Start-DeferredBinaryReplacement($Pending, $Destination) { $payload = @{ Pending = $Pending; Destination = $Destination } | ConvertTo-Json -Compress $payload64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($payload)) $script = @" `$ErrorActionPreference = 'SilentlyContinue' `$payload = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('$payload64')) | ConvertFrom-Json for (`$i = 0; `$i -lt 3600; `$i++) { if (-not (Test-Path -LiteralPath `$payload.Pending)) { exit 0 } Move-Item -LiteralPath `$payload.Pending -Destination `$payload.Destination -Force if (`$?) { exit 0 } Start-Sleep -Seconds 1 } exit 1 "@ $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($script)) $ps = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName Start-Process -FilePath $ps -ArgumentList @("-NoProfile", "-EncodedCommand", $encoded) -WindowStyle Hidden | Out-Null } function Apply-PendingBinaryReplacements($Dir) { if (-not (Test-Path -LiteralPath $Dir)) { return } Get-ChildItem -LiteralPath $Dir -File -Filter "*.pending.*" | ForEach-Object { $pending = $_.FullName $name = $_.Name $marker = ".pending." $index = $name.LastIndexOf($marker) if ($index -lt 1) { return } $destinationName = $name.Substring(0, $index) $destination = Join-Path $Dir $destinationName try { Move-Item -LiteralPath $pending -Destination $destination -Force Write-Info "Applied pending Dosh binary replacement: $destination" } catch { Write-Warning "pending Dosh binary replacement still waiting: $destination" } } } function Normalize-PathForCompare($Path) { [System.IO.Path]::GetFullPath($Path).TrimEnd( [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar ) } function PathList-Contains($PathValue, $PathToFind) { if (-not $PathValue) { return $false } $wanted = Normalize-PathForCompare $PathToFind foreach ($entry in ($PathValue -split ';' | Where-Object { $_ })) { try { if ((Normalize-PathForCompare $entry) -ieq $wanted) { return $true } } catch { } } return $false } function Assert-NoRelativePathSegments($Path) { foreach ($segment in ($Path -split '[\\/]')) { if ($segment -eq "." -or $segment -eq "..") { throw "refusing unsafe update cache path: $Path" } } } function Assert-SafeUpdateCache($Path) { if (-not $Path) { throw "refusing unsafe update cache path: $Path" } Assert-NoRelativePathSegments $Path $full = Normalize-PathForCompare $Path $root = Normalize-PathForCompare ([System.IO.Path]::GetPathRoot($full)) $homePath = Normalize-PathForCompare $HOME $homeCache = Normalize-PathForCompare (Join-Path $HOME ".cache") $unsafe = $full -eq $root -or $full -eq $homePath -or $full -eq $homeCache if (-not $unsafe -and $env:LOCALAPPDATA) { $localAppData = Normalize-PathForCompare $env:LOCALAPPDATA $localAppDataDosh = Normalize-PathForCompare (Join-Path $env:LOCALAPPDATA "dosh") $unsafe = $full -eq $localAppData -or $full -eq $localAppDataDosh } if ($unsafe) { throw "refusing unsafe update cache path: $Path" } $full } function Add-UserPath($PathToAdd) { $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $entries = @() if ($userPath) { $entries = @($userPath -split ';' | Where-Object { $_ }) } $wanted = Normalize-PathForCompare $PathToAdd if (-not (PathList-Contains $env:Path $PathToAdd)) { $env:Path = "$PathToAdd;$env:Path" } foreach ($entry in $entries) { if ((Normalize-PathForCompare $entry) -ieq $wanted) { return } } $next = if ($entries.Count -gt 0) { (@($entries) + $PathToAdd) -join ';' } else { $PathToAdd } [Environment]::SetEnvironmentVariable("Path", $next, "User") } function Write-Utf8NoBom($Path, $Content) { $encoding = New-Object System.Text.UTF8Encoding -ArgumentList $false [System.IO.File]::WriteAllText($Path, $Content, $encoding) } function Write-PathStatus($PathToCheck) { if (PathList-Contains $env:Path $PathToCheck) { Write-Info "Current terminal PATH includes Dosh." } else { Write-Info "Open a new terminal if dosh is not found on PATH." } } $bindir = Normalize-PathForCompare (Join-Path $Prefix "bin") $configDir = Normalize-PathForCompare (Join-Path $HOME ".config\dosh") New-Item -ItemType Directory -Force -Path $bindir, $configDir | Out-Null if ($env:DOSH_INSTALL_BINDIR_FILE) { Write-Utf8NoBom $env:DOSH_INSTALL_BINDIR_FILE $bindir } Apply-PendingBinaryReplacements $bindir function Install-Prebuilt { $url = Release-DownloadUrl if (-not $url) { return $false } $script:ResolvedSourceRef = Source-ReleaseRef $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-Info "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 $client = Get-ChildItem -Path $extract -Recurse -File -Filter "dosh-client.exe" | Select-Object -First 1 if (-not $client) { throw "prebuilt archive missing dosh-client.exe" } Install-Binary $client.FullName (Join-Path $bindir "dosh-client.exe") $bench = Get-ChildItem -Path $extract -Recurse -File -Filter "dosh-bench.exe" | Select-Object -First 1 if ($bench) { Install-Binary $bench.FullName (Join-Path $bindir "dosh-bench.exe") } Install-Binary $client.FullName (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 { Ensure-Cargo if ($FromCurrent) { if (-not (Test-Path "Cargo.toml")) { throw "-FromCurrent requires a Dosh checkout with Cargo.toml" } $src = (Get-Location).Path } else { if (-not $Repo) { throw "DOSH_REPO is required when running the installer from irm/iex" } Ensure-Git $sourceCache = Assert-SafeUpdateCache $UpdateCache $parent = Split-Path -Parent $sourceCache New-Item -ItemType Directory -Force -Path $parent | Out-Null $sourceRef = if ($script:ResolvedSourceRef) { $script:ResolvedSourceRef } else { "main" } if (Test-Path (Join-Path $sourceCache ".git")) { git -C $sourceCache remote set-url origin $Repo $fetchArgs = @("-C", $sourceCache, "fetch", "--depth", "1", "origin", $sourceRef) if ($Quiet) { $fetchArgs = @("-C", $sourceCache, "fetch", "-q", "--depth", "1", "origin", $sourceRef) } git @fetchArgs if ($sourceRef -eq "main") { git -C $sourceCache checkout -q -B main FETCH_HEAD } else { git -C $sourceCache checkout -q --detach FETCH_HEAD } } else { if (Test-Path $sourceCache) { Remove-Item -Recurse -Force $sourceCache } $cloneArgs = @("clone", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache) if ($Quiet) { $cloneArgs = @("clone", "-q", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache) } git @cloneArgs | Out-Null } $src = $sourceCache } try { Push-Location $src $buildArgs = @("build", "--release", "--bin", "dosh-client") if ($Quiet) { $buildArgs = @("build", "-q", "--release", "--bin", "dosh-client") } cargo @buildArgs 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") if (Test-Path "target\release\dosh-bench.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-Info "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`"" } $credentialCache = if ($env:LOCALAPPDATA) { (Join-Path $env:LOCALAPPDATA "dosh\credentials").Replace('\', '/') } else { "~/.local/share/dosh/credentials" } $clientToml = @" 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 = "$credentialCache" auth_preference = "native,ssh" trust_on_first_use = false native_auth_timeout_ms = 700 known_hosts = "~/.config/dosh/known_hosts" identity_files = ["~/.ssh/id_ed25519"] use_ssh_agent = true forward_agent = false escape_key = "^]" "@ Write-Utf8NoBom $clientConfig $clientToml } $hostsConfig = Join-Path $configDir "hosts.toml" if ($ForceConfig -or -not (Test-Path $hostsConfig)) { $defaultServer = if ($Server) { $Server } else { "user@example.com" } $hostUdpLine = if ($DoshHost) { "dosh_host = `"$DoshHost`"" } else { "# dosh_host = `"server.example.com`"" } $hostsToml = @" # Example: # [server] # ssh = "server" # dosh_host = "server.example.com" # port = 50000 # default_command = "tm" # predict = true [default] ssh = "$defaultServer" $hostUdpLine port = $Port predict = true "@ Write-Utf8NoBom $hostsConfig $hostsToml } Add-UserPath $bindir Write-Info "Installed Dosh client to $bindir" Write-Info "Configured UDP port $Port" Write-Info "" $displayServer = if ($Server) { $Server } else { "user@host" } Write-Info "Client commands:" Write-Info " $bindir\dosh.exe $displayServer" Write-Info " $bindir\dosh.exe setup " Write-Info " $bindir\dosh.exe update --check" Write-Info "" Write-Info "Client config:" Write-Info " $configDir\client.toml" Write-Info " $configDir\hosts.toml" Write-Info "" Write-PathStatus $bindir