private/Get-WtwRemoteTarget.ps1
|
function Format-WtwSshError { <# .SYNOPSIS Turn a raw ssh failure into something actionable. .DESCRIPTION wtw connects with BatchMode=yes so a hung prompt can never block a command — which means ssh's interactive remedies never appear. The two that actually happen on a LAN both look like a bare one-line failure, so they get translated here. .PARAMETER HostEntry Host entry, used to name the fix commands. .PARAMETER ErrorText Raw stderr from ssh. #> [CmdletBinding()] param( [Parameter(Mandatory)] $HostEntry, [Parameter(Mandatory)] [string] $ErrorText ) $text = $ErrorText.Trim() if ($text -match 'Host key verification failed') { return @( "ssh could not verify the host key for '$($HostEntry.Name)'." "This is expected the first time you reach a machine by a new name — the key is" "trusted per address, so switching to an mDNS name needs it accepted again." "Fix: wtw host trust $($HostEntry.Name) (shows fingerprints, asks first)" " or: ssh $($HostEntry.Name) (accept the prompt once)" ) -join "`n" } if ($text -match 'Permission denied') { return @( "ssh was refused by '$($HostEntry.Name)' ($($HostEntry.User)@$($HostEntry.HostName))." "Check the key is authorised there, or set one: wtw host add $($HostEntry.Name) --identity <path>" ) -join "`n" } if ($text -match 'Could not resolve hostname|Name or service not known') { return "ssh cannot resolve '$($HostEntry.HostName)'. Candidates: $((@($HostEntry.HostNames)) -join ', '). Re-probe with: wtw host sync" } if ($text -match 'wtw-pwsh-not-found' -or $text -match 'command not found: pwsh' -or $text -match 'pwsh: command not found' -or $text -match "'pwsh' is not recognized") { return @( "PowerShell (pwsh) was not found on '$($HostEntry.Name)'." "wtw drives the remote through pwsh, and an ssh command runs a non-login shell —" "so a PATH set up in ~/.zprofile or ~/.bashrc does not apply." " Install it: brew install powershell | winget install Microsoft.PowerShell" " Already installed? Point wtw at it directly:" " wtw host add $($HostEntry.Name) --pwsh /full/path/to/pwsh" " Find the path on that machine with: which pwsh" ) -join "`n" } if ($text -match 'Connection refused') { return @( "'$($HostEntry.HostName)' is reachable but nothing is listening on ssh." "The machine is up; its ssh *server* is off." " macOS: System Settings -> General -> Sharing -> Remote Login = On" " Windows: Start-Service sshd (and Set-Service sshd -StartupType Automatic)" " Linux: sudo systemctl enable --now ssh" ) -join "`n" } if ($text -match 'Connection timed out|No route to host') { return "No answer from '$($HostEntry.HostName)'. Is the machine awake and on the same network? Re-probe with: wtw host sync" } return "ssh to '$($HostEntry.Name)' failed: $text" } function New-WtwRemotePwshCommand { <# .SYNOPSIS Build the remote-side command that runs pwsh with an encoded payload. .DESCRIPTION `ssh host <command>` runs a NON-login, NON-interactive shell. zsh reads only ~/.zshenv then, and Homebrew puts its PATH export in ~/.zprofile — so on an Apple-Silicon Mac `pwsh` lives at /opt/homebrew/bin/pwsh and is simply not on PATH, giving "command not found: pwsh" even though an interactive ssh session finds it fine. /etc/paths does not list Homebrew either, so a login shell is not a reliable fix. POSIX remotes therefore get a tiny sh loop that tries the known install locations and execs the first hit. It is written without a single quote character: the whole thing crosses PowerShell argument binding, ssh's argv concatenation and the remote shell's parser, and every quote is one more thing to be mangled on the way. Base64 is likewise quote-free. Windows remotes keep the plain form — cmd.exe cannot run the sh loop, and PowerShell there is always on PATH. .PARAMETER Encoded Base64 (UTF-16LE) payload for -EncodedCommand. .PARAMETER HostEntry Host entry; supplies Platform and an optional explicit Pwsh path. .OUTPUTS String[] to append to the ssh argument list. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Encoded, [Parameter(Mandatory)] $HostEntry ) $explicit = Get-WtwPropertyValue -Object $HostEntry -Name 'Pwsh' if ($HostEntry.Platform -ieq 'windows') { $exe = if ($explicit) { $explicit } else { 'pwsh' } return @($exe, '-NoLogo', '-NoProfile', '-EncodedCommand', $Encoded) } $candidates = @() if ($explicit) { $candidates += $explicit } $candidates += @( 'pwsh' '/opt/homebrew/bin/pwsh' # Apple Silicon Homebrew '/usr/local/bin/pwsh' # Intel Homebrew, manual installs '/usr/bin/pwsh' # distro packages '/snap/bin/pwsh' '$HOME/.dotnet/tools/pwsh' # dotnet global tool ) # Assembled from single-quoted pieces on purpose: `$c` and `$HOME` belong to # the REMOTE shell, and a double-quoted PowerShell string would expand them # here (to nothing) before ssh ever saw them. $loop = 'for c in ' + ($candidates -join ' ') + '; do command -v $c >/dev/null 2>&1 && exec $c -NoLogo -NoProfile -EncodedCommand ' + $Encoded + '; done; echo wtw-pwsh-not-found >&2; exit 127' # Comma operator: a bare `return @(...)` unrolls this single-element array to # the string itself, and the caller's `+` would then splat it per character. return , @($loop) } function Write-WtwSshFailure { <# .SYNOPSIS Print an ssh failure as readable lines. .DESCRIPTION Write-Error and Write-Warning reflow embedded newlines into a single wrapped paragraph, which turns a copy-pasteable fix command into prose. The guidance goes to the host line by line; the caller still raises a short error so exit codes and -ErrorAction keep working. #> [CmdletBinding()] param( [Parameter(Mandatory)] $HostEntry, [Parameter(Mandatory)] [string] $ErrorText ) Write-Host '' foreach ($line in (Format-WtwSshError -HostEntry $HostEntry -ErrorText $ErrorText) -split "`n") { $color = if ($line -match '^\s*(Fix:|or:| macOS:| Windows:| Linux:|wtw |ssh )') { 'Cyan' } else { 'Yellow' } Write-Host " $line" -ForegroundColor $color } Write-Host '' } function New-WtwRemoteScript { <# .SYNOPSIS Build the PowerShell wtw invokes on the remote machine. .DESCRIPTION Deliberately does NOT call the remote's `wtw` command. On Windows that name resolves to the cmd.exe shim, which treats any subcommand it does not recognise as a `go` target — so `wtw __aliases` came back as 'could not resolve "__aliases"' instead of the registry dump. Importing the module and calling Invoke-Wtw directly skips the shim, the remote profile, and any shell-specific aliasing. `~/.wtw/module` is where `wtw install` puts it; a PSGallery install is the fallback. .PARAMETER Arguments wtw arguments, e.g. @('__resolve_json', 'auth'). .PARAMETER WtwCommand Optional override from the host config. When set to anything other than the default, that command is invoked verbatim instead — an escape hatch for non-standard remote installs. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string[]] $Arguments, [string] $WtwCommand ) $quoted = @($Arguments | ForEach-Object { "'" + ([string]$_).Replace("'", "''") + "'" }) -join ' ' if ($WtwCommand -and $WtwCommand -ne 'wtw') { return "$WtwCommand $quoted" } # OutputRendering=Ansi: without it PowerShell strips colour the moment stdout # is not a terminal, and ssh without a TTY is never a terminal — so a # delegated `wtw list` would come back monochrome. Forcing Ansi keeps the # remote's own rendering (including the colour swatches) intact. # WTW_NO_UPDATE_NOTICE: the remote would otherwise prepend its "newer version # available" hint to output we are about to parse or print. $prelude = @' $ErrorActionPreference = 'Stop' $env:WTW_NO_UPDATE_NOTICE = '1' if ($PSStyle) { $PSStyle.OutputRendering = 'Ansi' } $wtwModule = Join-Path $HOME '.wtw' 'module' 'wtw.psm1' if (Test-Path $wtwModule) { Import-Module $wtwModule -Force -DisableNameChecking } else { Import-Module wtw -DisableNameChecking } '@ return "$prelude`nInvoke-Wtw $quoted" } function Invoke-WtwRemoteCommand { <# .SYNOPSIS Run a wtw subcommand on a remote host over SSH. .DESCRIPTION The remote machine already runs wtw, and its registry is the only thing that knows where a worktree currently lives over there. Rather than mirroring that state locally (which would go stale the moment an agent created a worktree), local wtw asks the remote wtw. The script travels as -EncodedCommand. ssh concatenates its command arguments and hands the result to the remote's *default shell* — cmd.exe on Windows — so anything with quotes in it gets reinterpreted on the way in. Base64 has no characters cmd.exe or sh treat specially, which makes the payload identical on every remote platform. -NoProfile keeps a chatty remote profile out of stdout; the internal `__*` commands already suppress the update notice for the same reason. .PARAMETER HostEntry Host entry from Resolve-WtwHost. .PARAMETER Arguments wtw arguments, e.g. @('__resolve_json', 'auth'). .OUTPUTS @{ Success; Output; Error } — Output is the raw stdout lines. #> [CmdletBinding()] param( [Parameter(Mandatory)] $HostEntry, [Parameter(Mandatory)] [string[]] $Arguments ) if (-not (Get-Command ssh -ErrorAction SilentlyContinue)) { return @{ Success = $false; Output = @(); Error = 'ssh is not on PATH.' } } $remoteScript = New-WtwRemoteScript -Arguments $Arguments -WtwCommand $HostEntry.Wtw # -EncodedCommand wants UTF-16LE, which is what [Text.Encoding]::Unicode is. $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($remoteScript)) $sshArgs = @('-o', 'BatchMode=yes', $HostEntry.Name) + (New-WtwRemotePwshCommand -Encoded $encoded -HostEntry $HostEntry) # `6>$null` is load-bearing. A remote pwsh with redirected streams serialises # its *information* stream — everything wtw prints with Write-Host — as a # CLIXML blob on stderr, in addition to the plain text already on stdout. # Local PowerShell recognises the `#< CLIXML` preamble and rehydrates those # records into ITS information stream, which the host then prints. Assignment # does not capture stream 6, so the output appeared twice: once echoed by the # host, once in the variable. Discarding 6 drops the duplicate; the text we # actually want is already on stdout. $merged = & ssh @sshArgs 2>&1 6>$null $code = $LASTEXITCODE $stdout = @($merged | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_" }) $stdErrLines = @($merged | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { "$_" } | Where-Object { $_ -notmatch '^#< CLIXML' -and $_ -notmatch '^<Objs ' }) return @{ Success = ($code -eq 0) Output = $stdout Error = if ($stdErrLines.Count -gt 0) { ($stdErrLines -join "`n").Trim() } else { $null } } } function Get-WtwRemoteTarget { <# .SYNOPSIS Resolve a worktree name on a remote host to its path + workspace file. .DESCRIPTION Prefers `wtw __resolve_json`, which returns the workspace file path as well. Falls back to the older tab-delimited `__resolve` so a machine running a wtw that predates this feature still opens — it just loses the `.code-workspace` and opens the folder instead. .PARAMETER HostEntry Host entry from Resolve-WtwHost. .PARAMETER Name Target name as the *remote* wtw knows it (its aliases, not ours). .OUTPUTS @{ Path; Workspace; Color; Title; PrettyName } or $null. #> [CmdletBinding()] param( [Parameter(Mandatory)] $HostEntry, [Parameter(Mandatory)] [string] $Name ) $result = Invoke-WtwRemoteCommand -HostEntry $HostEntry -Arguments @('__resolve_json', $Name) if ($result.Success -and $result.Output.Count -gt 0) { $json = ($result.Output -join "`n").Trim() if ($json.StartsWith('{')) { try { $parsed = $json | ConvertFrom-Json return @{ Path = Get-WtwPropertyValue -Object $parsed -Name 'path' Workspace = Get-WtwPropertyValue -Object $parsed -Name 'workspace' Color = Get-WtwPropertyValue -Object $parsed -Name 'color' Title = Get-WtwPropertyValue -Object $parsed -Name 'title' PrettyName = Get-WtwPropertyValue -Object $parsed -Name 'prettyName' } } catch { Write-Verbose "Remote __resolve_json returned unparseable JSON: $json" } } } # Older remote wtw: tab-delimited path\tcolor\ttitle\t... $legacy = Invoke-WtwRemoteCommand -HostEntry $HostEntry -Arguments @('__resolve', $Name) if (-not $legacy.Success -or $legacy.Output.Count -eq 0) { # Surface the ssh failure. Without this, a connection problem was # indistinguishable from "that worktree does not exist over there". $sshError = if ($legacy.Error) { $legacy.Error } else { $result.Error } if ($sshError) { Write-WtwSshFailure -HostEntry $HostEntry -ErrorText $sshError } return $null } $fields = ($legacy.Output | Select-Object -First 1) -split "`t" if (-not $fields[0]) { return $null } Write-Host " Remote wtw predates --on; opening the folder (no workspace file)." -ForegroundColor DarkGray return @{ Path = $fields[0] Workspace = $null Color = if ($fields.Count -gt 1) { $fields[1] } else { $null } Title = if ($fields.Count -gt 2) { $fields[2] } else { $null } PrettyName = $null } } |