public/Invoke-WtwHost.ps1

function Invoke-WtwHost {
    <#
    .SYNOPSIS
        Manage the remote machines wtw can open worktrees on.
    .DESCRIPTION
        Hosts are stored in ~/.wtw/config.json under `hosts`, and mirrored into
        ~/.ssh/config.d/wtw so the editor's Remote-SSH extension — which resolves
        hosts through the ssh client, not through wtw — can see them too.

        Subcommands:
          list Show configured hosts and their ssh status
          discover [--yes] [--exclude a,b] Find tailnet machines and register them
          add <name> [options] Add or update a host, then sync ssh config
          remove <name> Drop a host, then sync ssh config
          sync Re-probe addresses, rewrite ~/.ssh/config.d/wtw
          trust <name> Show host-key fingerprints, then add to known_hosts
          test <name> Probe addresses, ssh config, and the remote wtw
    .PARAMETER Action
        Subcommand (default: list).
    .PARAMETER Name
        Host name for add / remove / test.
    .PARAMETER Alias
        Short aliases, e.g. "at,troll". Typed as an array because PowerShell's
        argument mode already turns a bare `at,troll` into two arguments, while a
        quoted 'at,troll' arrives as one string — both forms are accepted.
    .PARAMETER User
        SSH user.
    .PARAMETER Address
        Ordered candidate addresses for ssh. Prefer an mDNS name
        (`laptop.local`) first and a last-known IP as backup: on DHCP — or
        when a machine moves between wifi and a USB adapter — the address changes
        but the .local name does not. `wtw host sync` writes whichever candidate
        answers on the ssh port.
    .PARAMETER Identity
        IdentityFile path.
    .PARAMETER Port
        SSH port.
    .PARAMETER Platform
        Remote OS: windows | linux | macos. Drives remote path translation.
    .PARAMETER Wtw
        Command used to invoke wtw on the remote (default: wtw).
    .EXAMPLE
        wtw host add workstation --alias at --user dev --address 192.168.1.10 --platform windows --identity ~/.ssh/id_ed25519_workstation
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)] [string] $Action = 'list',
        [Parameter(Position = 1)] [string] $Name,
        [string[]] $Alias,
        [string] $User,
        [string[]] $Address,
        [string] $Identity,
        [string] $Port,
        [ValidateSet('windows', 'linux', 'macos')] [string] $Platform = 'linux',
        [string] $Wtw,
        [string] $Pwsh,
        [switch] $Yes,
        [string[]] $Exclude,
        [ValidateSet('tailscale', 'zerotier', 'mdns', 'lan', 'any')] [string] $Via
    )

    switch ($Action.ToLowerInvariant()) {
        'list' {
            $hosts = Get-WtwHosts
            if ($hosts.Count -eq 0) {
                Write-Host ''
                Write-Host ' No remote hosts configured.' -ForegroundColor Yellow
                Write-Host ' Add one: wtw host add workstation --alias at --user dev --address 192.168.1.10 --platform windows' -ForegroundColor DarkGray
                Write-Host ''
                return
            }
            Write-Host ''
            $ztPrefixes = Get-WtwZeroTierPrefixes
            foreach ($h in ($hosts | Sort-Object { $_.Name })) {
                $aliases = if (@($h.Aliases).Count -gt 0) { " (" + (@($h.Aliases) -join ', ') + ")" } else { '' }
                # Which address ssh will actually use, and over which network —
                # "is this going over the tailnet or the LAN" is the first thing
                # you want to know and was previously invisible. Read from
                # `ssh -G`, so it reflects the merged config rather than what wtw
                # last intended to write.
                $effective = Get-WtwSshEffectiveHostName -Name $h.Name
                $known = if ($effective) { 'ssh ok' } else { 'not in ssh config — run: wtw host sync' }
                $knownColor = if ($effective) { 'DarkGray' } else { 'Yellow' }

                $active = if ($effective) { $effective } else { ($h.HostNames | Select-Object -First 1) }
                $kind = if ($active) { Get-WtwAddressKind -Address $active -ZeroTierPrefixes $ztPrefixes } else { '-' }

                Write-Host " $($h.Name)$aliases" -ForegroundColor Cyan -NoNewline
                Write-Host " $($h.User)@$active [$($h.Platform)]" -ForegroundColor White -NoNewline
                Write-Host " via $kind" -ForegroundColor Green -NoNewline
                Write-Host " $known" -ForegroundColor $knownColor
                if (@($h.HostNames).Count -gt 1) {
                    $labelled = @($h.HostNames | ForEach-Object {
                            "$_ [$(Get-WtwAddressKind -Address $_ -ZeroTierPrefixes $ztPrefixes)]"
                        })
                    Write-Host " candidates: $($labelled -join ', ')" -ForegroundColor DarkGray
                }
            }
            Write-Host ''
            Write-Host ' Details: wtw host show [name]' -ForegroundColor DarkGray
            Write-Host ''
        }

        'add' {
            if (-not $Name) { Write-Error 'Usage: wtw host add <name> [--alias a,b] [--user u] [--address ip] [--identity path] [--port n] [--platform windows|linux|macos]'; return }

            $config = Get-WtwConfig
            if (-not $config) { $config = New-WtwDefaultConfig }

            $hosts = Get-WtwPropertyValue -Object $config -Name 'hosts'
            if (-not $hosts) { $hosts = [PSCustomObject]@{} }

            $existing = Get-WtwPropertyValue -Object $hosts -Name $Name
            $entry = if ($existing) { $existing } else { [PSCustomObject]@{} }

            # Only overwrite what was actually passed, so `wtw host add x --platform windows`
            # is a targeted edit rather than a silent reset of the other fields.
            if ($Alias)    { $entry | Add-Member -NotePropertyName 'aliases' -NotePropertyValue (Split-WtwAliasList -Value $Alias) -Force }
            if ($User)     { $entry | Add-Member -NotePropertyName 'user' -NotePropertyValue $User -Force }
            if ($Address) {
                # Stored as an ordered candidate list. Put the mDNS name first
                # and a last-known IP second: on DHCP the address moves, the
                # .local name does not.
                $entry | Add-Member -NotePropertyName 'hostNames' -NotePropertyValue (Split-WtwAliasList -Value $Address) -Force
                # Drop the pre-0.2 single-value key so the two cannot disagree.
                if ((Get-WtwPropertyNames -Object $entry) -contains 'hostName') {
                    $entry.PSObject.Properties.Remove('hostName')
                }
            }
            if ($Identity) { $entry | Add-Member -NotePropertyName 'identityFile' -NotePropertyValue $Identity -Force; $entry | Add-Member -NotePropertyName 'identitiesOnly' -NotePropertyValue $true -Force }
            if ($Port)     { $entry | Add-Member -NotePropertyName 'port' -NotePropertyValue $Port -Force }
            if ($Wtw)      { $entry | Add-Member -NotePropertyName 'wtw' -NotePropertyValue $Wtw -Force }
            if ($Pwsh)     { $entry | Add-Member -NotePropertyName 'pwsh' -NotePropertyValue $Pwsh -Force }
            if ($Via)      { $entry | Add-Member -NotePropertyName 'via' -NotePropertyValue $Via -Force }
            if ($PSBoundParameters.ContainsKey('Platform') -or -not (Get-WtwPropertyValue -Object $entry -Name 'platform')) {
                $entry | Add-Member -NotePropertyName 'platform' -NotePropertyValue $Platform -Force
            }

            $hosts | Add-Member -NotePropertyName $Name -NotePropertyValue $entry -Force
            $config | Add-Member -NotePropertyName 'hosts' -NotePropertyValue $hosts -Force
            Save-WtwConfig $config

            Write-Host " Saved host '$Name'." -ForegroundColor Green
            Sync-WtwSshConfig | Out-Null
        }

        'remove' {
            if (-not $Name) { Write-Error 'Usage: wtw host remove <name>'; return }
            $config = Get-WtwConfig
            $hosts = Get-WtwPropertyValue -Object $config -Name 'hosts'
            if (-not $hosts -or -not ((Get-WtwPropertyNames -Object $hosts) -contains $Name)) {
                Write-Error "Host '$Name' is not configured."
                return
            }
            $hosts.PSObject.Properties.Remove($Name)
            Save-WtwConfig $config
            Write-Host " Removed host '$Name'." -ForegroundColor Green
            Sync-WtwSshConfig | Out-Null
        }

        'sync' {
            $hosts = Get-WtwHosts
            if ($hosts.Count -eq 0) {
                Write-Host ' No hosts configured — nothing to sync.' -ForegroundColor Yellow
                return
            }
            Sync-WtwSshConfig | Out-Null
        }

        'discover' {
            # No @() wrapper — these return `,@(...)` already; re-wrapping nests
            # the array and every $item.Property would read the wrapper.
            $peers = Get-WtwTailscalePeers
            if ($peers.Count -eq 0) {
                Write-Host ''
                Write-Host ' No tailnet found.' -ForegroundColor Yellow
                if (-not (Get-WtwTailscaleCommand)) {
                    Write-Host ' The tailscale CLI is not installed here. https://tailscale.com/download' -ForegroundColor DarkGray
                } else {
                    Write-Host ' tailscale is installed but reported nothing — is it logged in and up?' -ForegroundColor DarkGray
                    Write-Host ' tailscale status' -ForegroundColor DarkGray
                }
                Show-WtwZeroTierHint
                return
            }

            # Exclusions persist. A NAS or a router is on the tailnet forever, so
            # a one-shot flag would mean re-declining it on every discover.
            $config = Get-WtwConfig
            $ignored = @(Get-WtwPropertyValue -Object $config -Name 'discoverIgnore' -DefaultValue @())
            if ($Exclude) {
                $ignored = @($ignored + (Split-WtwAliasList -Value $Exclude)) | Select-Object -Unique
                if (-not $config) { $config = New-WtwDefaultConfig }
                $config | Add-Member -NotePropertyName 'discoverIgnore' -NotePropertyValue @($ignored) -Force
                Save-WtwConfig $config
                Write-Host " Ignoring from now on: $((@($ignored)) -join ', ')" -ForegroundColor DarkGray
            }

            $existingHosts = Get-WtwHosts
            $plan = Resolve-WtwPeerPlan -Peers $peers -Hosts $existingHosts -Ignore $ignored

            Write-Host ''
            Write-Host ' Tailnet machines' -ForegroundColor Cyan
            Write-Host ''
            foreach ($item in $plan) {
                $color = switch ($item.Action) {
                    'add' { 'Green' } 'update' { 'Yellow' } 'ok' { 'DarkGray' } default { 'DarkGray' }
                }
                $state = if ($item.Online) { 'online ' } else { 'offline' }
                Write-Host (" {0,-8} {1,-24} {2,-8} {3,-8} {4}" -f $item.Action, $item.Name, ($item.Platform ?? '-'), $state, $item.Reason) -ForegroundColor $color
                if ($item.AddAddresses.Count -gt 0) {
                    Write-Host (" + " + (@($item.AddAddresses) -join ', ')) -ForegroundColor DarkGray
                }
            }
            Write-Host ''

            $actionable = @($plan | Where-Object { $_.Action -in @('add', 'update') })
            if ($actionable.Count -eq 0) {
                Write-Host ' Nothing to change.' -ForegroundColor Green
                Show-WtwZeroTierHint
                return
            }

            # Tailscale knows the machine but not which account you ssh in as.
            $sshUser = if ($User) { $User } elseif ($env:USER) { $env:USER } else { $env:USERNAME }
            Write-Host " New hosts will use ssh user '$sshUser' (override with --user)." -ForegroundColor DarkGray

            if (-not $Yes) {
                $answer = Read-Host " Apply these $($actionable.Count) change(s)? [y/N]"
                if ($answer -notin @('y', 'Y', 'yes')) {
                    Write-Host ' No changes made.' -ForegroundColor DarkGray
                    return
                }
            }

            $config = Get-WtwConfig
            if (-not $config) { $config = New-WtwDefaultConfig }
            $hostMap = Get-WtwPropertyValue -Object $config -Name 'hosts'
            if (-not $hostMap) { $hostMap = [PSCustomObject]@{} }

            foreach ($item in $actionable) {
                $entry = Get-WtwPropertyValue -Object $hostMap -Name $item.Name
                if (-not $entry) { $entry = [PSCustomObject]@{} }

                $entry | Add-Member -NotePropertyName 'hostNames' -NotePropertyValue @($item.Addresses) -Force
                if ((Get-WtwPropertyNames -Object $entry) -contains 'hostName') {
                    $entry.PSObject.Properties.Remove('hostName')
                }
                if ($item.Action -eq 'add') {
                    $entry | Add-Member -NotePropertyName 'user' -NotePropertyValue $sshUser -Force
                    $entry | Add-Member -NotePropertyName 'platform' -NotePropertyValue $item.Platform -Force
                } elseif (-not (Get-WtwPropertyValue -Object $entry -Name 'platform')) {
                    $entry | Add-Member -NotePropertyName 'platform' -NotePropertyValue $item.Platform -Force
                }

                $hostMap | Add-Member -NotePropertyName $item.Name -NotePropertyValue $entry -Force
                Write-Host " $($item.Action): $($item.Name)" -ForegroundColor Green
            }

            $config | Add-Member -NotePropertyName 'hosts' -NotePropertyValue $hostMap -Force
            Save-WtwConfig $config
            Sync-WtwSshConfig | Out-Null

            Write-Host ''
            Write-Host ' Next: wtw host trust <name> then wtw host test <name>' -ForegroundColor DarkGray
            Write-Host ' Aliases are not invented for you — add one with: wtw host add <name> --alias <a>' -ForegroundColor DarkGray
            Write-Host ''
            Show-WtwZeroTierHint
        }

        { $_ -in 'show', 'info' } {
            $targets = if ($Name) {
                $entry = Resolve-WtwHost -Name $Name
                if (-not $entry) { Write-Error "Host '$Name' is not configured. See: wtw host list"; return }
                @($entry)
            } else {
                Get-WtwHosts
            }
            if (@($targets).Count -eq 0) {
                Write-Host ''
                Write-Host ' No remote hosts configured. Try: wtw host discover' -ForegroundColor Yellow
                Write-Host ''
                return
            }

            $ztPrefixes = Get-WtwZeroTierPrefixes
            foreach ($h in @($targets)) {
                $resolved = Resolve-WtwHostAddress -HostEntry $h
                $port = if ($h.Port) { [int]$h.Port } else { 22 }

                Write-Host ''
                Write-Host " $($h.Name)" -ForegroundColor Cyan -NoNewline
                if (@($h.Aliases).Count -gt 0) { Write-Host " (aliases: $((@($h.Aliases)) -join ', '))" -ForegroundColor DarkGray } else { Write-Host '' }

                Write-Host " ssh $($h.User)@$($resolved.Address) port $port" -ForegroundColor White
                Write-Host " platform $($h.Platform)" -ForegroundColor White
                if ($h.IdentityFile) { Write-Host " identity $($h.IdentityFile)" -ForegroundColor White }
                if ($h.Pwsh)         { Write-Host " pwsh $($h.Pwsh)" -ForegroundColor White }
                if ($h.Wtw -and $h.Wtw -ne 'wtw') { Write-Host " wtw command $($h.Wtw)" -ForegroundColor White }

                # NOT $via: PowerShell variable names are case-insensitive, so
                # assigning $via rebinds the $Via parameter and trips its
                # ValidateSet on an empty value.
                $preferredVia = Get-WtwPropertyValue -Object $h -Name 'Via'
                $viaLabel = if ($preferredVia -and $preferredVia -ne 'any') { $preferredVia } else { 'any (first reachable wins)' }
                Write-Host " prefer $viaLabel" -ForegroundColor White

                Write-Host ' addresses' -ForegroundColor White
                foreach ($candidate in @($h.HostNames)) {
                    $kind = Get-WtwAddressKind -Address $candidate -ZeroTierPrefixes $ztPrefixes
                    $up = Test-WtwAddressReachable -Address $candidate -Port $port
                    $active = if ($candidate -eq $resolved.Address) { '→' } else { ' ' }
                    $state = if ($up) { 'up ' } else { 'down' }
                    $color = if ($candidate -eq $resolved.Address) { 'Green' } elseif ($up) { 'White' } else { 'DarkGray' }
                    Write-Host (" {0} {1,-10} {2} {3}" -f $active, $kind, $state, $candidate) -ForegroundColor $color
                }
                if ($preferredVia -and $preferredVia -ne 'any' -and -not $resolved.Preferred) {
                    Write-Host " note: '$preferredVia' was preferred but is not reachable — using $($resolved.Kind)." -ForegroundColor Yellow
                }

                $conflicts = Get-WtwSshHostConflicts -Name $h.Name -Aliases $h.Aliases
                if ($conflicts.Count -gt 0) {
                    Write-Host " ssh config $($conflicts.Count) other Host block(s) also match this name:" -ForegroundColor Yellow
                    foreach ($conflict in $conflicts) {
                        Write-Host " $($conflict.File):$($conflict.Line)" -ForegroundColor DarkGray
                    }
                }
            }
            Write-Host ''
            Write-Host ' Change one: wtw host add <name> --via tailscale|lan|mdns|zerotier|any' -ForegroundColor DarkGray
            Write-Host ' Deep check: wtw host test <name>' -ForegroundColor DarkGray
            Write-Host ''
        }

        'trust' {
            if (-not $Name) { Write-Error 'Usage: wtw host trust <name>'; return }
            $entry = Resolve-WtwHost -Name $Name
            if (-not $entry) { Write-Error "Host '$Name' is not configured. See: wtw host list"; return }
            if (-not (Get-Command ssh-keyscan -ErrorAction SilentlyContinue)) {
                Write-Error "ssh-keyscan is not on PATH. Accept the key manually instead: ssh $($entry.Name)"
                return
            }

            # Every candidate address needs its own known_hosts entry — ssh
            # trusts a key per host pattern, so reaching the same machine by
            # .local after trusting its IP is a fresh, unverified connection.
            $targets = @($entry.Name) + @($entry.HostNames) | Where-Object { $_ } | Select-Object -Unique
            $port = if ($entry.Port) { [int]$entry.Port } else { 22 }

            $keys = @()
            foreach ($target in $targets) {
                $scanned = @(& ssh-keyscan -p $port -T 5 $target 2>$null | Where-Object { $_ -and $_ -notmatch '^#' })
                if ($scanned.Count -gt 0) { $keys += $scanned }
            }
            if ($keys.Count -eq 0) {
                Write-Error "ssh-keyscan got no keys for $($targets -join ', '). Is the machine up?"
                return
            }

            # Key material already in known_hosts, keyed by the base64 blob. A
            # scanned key that matches one you already trust under a different
            # name is the same machine answering to a new address — which turns
            # this from blind trust-on-first-use into a comparison you can check.
            $knownHostsPath = Join-Path $script:WtwSshConfigDir 'known_hosts'
            $trustedMaterial = @{}
            if (Test-Path $knownHostsPath) {
                foreach ($line in (Get-Content $knownHostsPath)) {
                    $parts = $line -split '\s+'
                    if ($parts.Count -ge 3 -and $parts[2]) { $trustedMaterial[$parts[2]] = $parts[0] }
                }
            }

            Write-Host ''
            Write-Host ' Host keys offered:' -ForegroundColor Cyan
            $anyAlreadyTrusted = $false
            foreach ($key in ($keys | Select-Object -Unique)) {
                $fields = $key -split '\s+'
                $fingerprint = '(fingerprint unavailable)'
                try {
                    $tmp = [System.IO.Path]::GetTempFileName()
                    Set-Content -Path $tmp -Value $key -Encoding ascii
                    $fingerprint = ((& ssh-keygen -l -f $tmp 2>$null) | Select-Object -First 1)
                    Remove-Item $tmp -Force -ErrorAction SilentlyContinue
                } catch { }

                $material = if ($fields.Count -ge 3) { $fields[2] } else { $null }
                $match = if ($material -and $trustedMaterial.ContainsKey($material)) { $trustedMaterial[$material] } else { $null }

                Write-Host " $($fields[0]) $fingerprint" -ForegroundColor White -NoNewline
                if ($match) {
                    $anyAlreadyTrusted = $true
                    Write-Host " ← same key you already trust for $match" -ForegroundColor Green
                } else {
                    Write-Host ''
                }
            }

            Write-Host ''
            if ($anyAlreadyTrusted) {
                Write-Host ' Keys marked above already match a host you trust, so those are the same' -ForegroundColor Green
                Write-Host ' machine reachable under another name.' -ForegroundColor Green
            } else {
                Write-Host ' None of these match a host you already trust. Verify against the machine:' -ForegroundColor Yellow
                Write-Host ' on that host, run: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub' -ForegroundColor DarkGray
                Write-Host ' (Windows OpenSSH: ssh-keygen -lf $env:ProgramData\ssh\ssh_host_ed25519_key.pub)' -ForegroundColor DarkGray
            }
            Write-Host ''

            $answer = Read-Host " Add these to ~/.ssh/known_hosts? [y/N]"
            if ($answer -notin @('y', 'Y', 'yes')) {
                Write-Host ' Not added.' -ForegroundColor DarkGray
                return
            }

            $knownHosts = Join-Path $script:WtwSshConfigDir 'known_hosts'
            if (-not (Test-Path $script:WtwSshConfigDir)) {
                New-Item -ItemType Directory -Path $script:WtwSshConfigDir -Force | Out-Null
            }
            $existing = if (Test-Path $knownHosts) { @(Get-Content $knownHosts) } else { @() }
            $added = 0
            foreach ($key in ($keys | Select-Object -Unique)) {
                if ($existing -notcontains $key) {
                    Add-Content -Path $knownHosts -Value $key -Encoding ascii
                    $added++
                }
            }
            Write-Host " Added $added key(s) to $knownHosts." -ForegroundColor Green
        }

        'test' {
            if (-not $Name) { Write-Error 'Usage: wtw host test <name>'; return }
            $entry = Resolve-WtwHost -Name $Name
            if (-not $entry) { Write-Error "Host '$Name' is not configured. See: wtw host list"; return }

            Write-Host ''
            Write-Host " addresses " -NoNewline
            $resolved = Resolve-WtwHostAddress -HostEntry $entry
            if ($resolved.Reachable) {
                Write-Host "$($resolved.Address) answers on ssh" -ForegroundColor Green
            } else {
                Write-Host "none of [$((@($entry.HostNames)) -join ', ')] answered" -ForegroundColor Red
            }

            Write-Host " ssh config " -NoNewline
            if (Test-WtwSshHostKnown -Name $entry.Name) {
                $configured = (& ssh -G $entry.Name 2>$null |
                        Where-Object { $_ -match '^hostname\s+(.+)$' } |
                        Select-Object -First 1) -replace '^hostname\s+', ''
                if ($resolved.Reachable -and $configured.Trim() -ne $resolved.Address) {
                    # The stored address went stale — normal on DHCP.
                    Write-Host "resolves to $($configured.Trim()), but $($resolved.Address) is the live one — run: wtw host sync" -ForegroundColor Yellow
                } else {
                    Write-Host 'resolves' -ForegroundColor Green
                }
            } else {
                Write-Host 'NOT resolvable — run: wtw host sync' -ForegroundColor Red
            }

            $conflicts = Get-WtwSshHostConflicts -Name $entry.Name -Aliases $entry.Aliases
            if ($conflicts.Count -gt 0) {
                Write-Host " other blocks " -NoNewline
                Write-Host "$($conflicts.Count) more Host block(s) match this name" -ForegroundColor Yellow
                foreach ($conflict in $conflicts) {
                    Write-Host " $($conflict.File):$($conflict.Line) $($conflict.Text)" -ForegroundColor DarkGray
                }
                Write-Host " wtw's block is Included first, so its HostName/User/IdentityFile win." -ForegroundColor DarkGray
                Write-Host " Harmless if they point at the same machine — but they now follow wtw." -ForegroundColor DarkGray
            }

            Write-Host " remote wtw " -NoNewline
            $probe = Invoke-WtwRemoteCommand -HostEntry $entry -Arguments @('__aliases')
            if ($probe.Success) {
                $count = @($probe.Output | Where-Object { $_ }).Count
                Write-Host "reachable ($count registered targets)" -ForegroundColor Green
            } else {
                Write-Host "unreachable" -ForegroundColor Red
                if ($probe.Error) {
                    foreach ($line in (Format-WtwSshError -HostEntry $entry -ErrorText $probe.Error) -split "`n") {
                        Write-Host " $line" -ForegroundColor DarkGray
                    }
                }
            }
            Write-Host ''
        }

        default {
            Write-Error "Unknown host action '$Action'. Use: list, discover, add, remove, sync, trust, test."
        }
    }
}