private/Resolve-WtwHost.ps1

$script:WtwSshConfigDir = Join-Path $HOME '.ssh'
$script:WtwSshConfigPath = Join-Path $script:WtwSshConfigDir 'config'
$script:WtwSshManagedRelative = 'config.d/wtw'

function Split-WtwAliasList {
    <#
    .SYNOPSIS
        Normalise an alias argument to a clean string array.
    .DESCRIPTION
        `wtw host add x --alias at,troll` reaches us as @('at','troll') because
        PowerShell's argument mode already splits on commas; the same command
        with the value quoted arrives as the single string 'at,troll'. Accept
        both, and split any element that still contains commas.
    #>

    [CmdletBinding()]
    param([AllowNull()] [string[]] $Value)

    if (-not $Value) { return , @() }
    return , @($Value |
            ForEach-Object { $_ -split ',' } |
            ForEach-Object { $_.Trim() } |
            Where-Object { $_ })
}

function Get-WtwHosts {
    <#
    .SYNOPSIS
        Remote machines declared in ~/.wtw/config.json.
    .DESCRIPTION
        Hosts live in the user config, not in a repo file. A repo-level map would
        duplicate what the remote's own wtw registry already knows and would go
        stale every time an agent creates or removes a worktree over there —
        which, in an agent-driven setup, is constantly.
    .OUTPUTS
        Array of @{ Name; Aliases; User; HostName; Port; IdentityFile;
                    IdentitiesOnly; Platform; Wtw }.
    #>

    [CmdletBinding()]
    param([AllowNull()] [object] $Config)

    if ($null -eq $Config) { $Config = Get-WtwConfig }
    $hosts = Get-WtwPropertyValue -Object $Config -Name 'hosts'
    if (-not $hosts) { return , @() }

    $result = @()
    foreach ($name in (Get-WtwPropertyNames -Object $hosts)) {
        $entry = Get-WtwPropertyValue -Object $hosts -Name $name

        # `hostNames` is an ordered candidate list — an mDNS name first, a
        # last-known IP as backup — because a laptop on DHCP (or moving between
        # wifi and a USB adapter) changes address constantly while its .local
        # name does not. `hostName` (singular) is the pre-0.2 single-value form.
        $candidates = @(Get-WtwPropertyValue -Object $entry -Name 'hostNames' -DefaultValue @())
        if ($candidates.Count -eq 0) {
            $legacy = Get-WtwPropertyValue -Object $entry -Name 'hostName'
            if ($legacy) { $candidates = @($legacy) }
        }

        $result += @{
            Name           = $name
            Aliases        = @(Get-WtwPropertyValue -Object $entry -Name 'aliases' -DefaultValue @())
            User           = Get-WtwPropertyValue -Object $entry -Name 'user'
            HostNames      = $candidates
            HostName       = ($candidates | Select-Object -First 1)
            Port           = Get-WtwPropertyValue -Object $entry -Name 'port'
            IdentityFile   = Get-WtwPropertyValue -Object $entry -Name 'identityFile'
            IdentitiesOnly = Get-WtwPropertyValue -Object $entry -Name 'identitiesOnly' -DefaultValue $false
            # Drives both the remote URI path shape (drive letters) and whether
            # remote.SSH.remotePlatform needs pinning in the editor's settings.
            Platform       = (Get-WtwPropertyValue -Object $entry -Name 'platform' -DefaultValue 'linux')
            Wtw            = (Get-WtwPropertyValue -Object $entry -Name 'wtw' -DefaultValue 'wtw')
            # Explicit pwsh path, for installs the standard probe list misses.
            Pwsh           = (Get-WtwPropertyValue -Object $entry -Name 'pwsh')
            # Preferred transport: tailscale | zerotier | mdns | lan | any.
            Via            = (Get-WtwPropertyValue -Object $entry -Name 'via')
            # Identity in terminal titles: a per-machine emoji and a short label,
            # so a remote tab is recognisable as "that machine" at a glance.
            Emoji          = (Get-WtwPropertyValue -Object $entry -Name 'emoji')
            Label          = (Get-WtwPropertyValue -Object $entry -Name 'label')
            # What goes between the machine label and the worktree title. '.' by
            # default; a space or '' reads better when the worktree name starts
            # with its own emoji.
            Separator      = (Get-WtwPropertyValue -Object $entry -Name 'separator' -DefaultValue '.')
        }
    }
    # Comma operator: without it a single configured host unrolls to a bare
    # hashtable, and `(Get-WtwHosts)[0]` becomes a key lookup returning $null.
    return , @($result)
}

function Resolve-WtwHost {
    <#
    .SYNOPSIS
        Resolve a host name or alias to its configured entry.
    .DESCRIPTION
        Exact name, then exact alias, then unique prefix across both. Prefix
        resolution is deliberately strict: an ambiguous prefix returns $null
        rather than guessing, because guessing wrong here opens an editor
        against the wrong machine's filesystem.
    .PARAMETER Name
        Host name or alias ('workstation', 'at').
    .PARAMETER Hosts
        Pre-read host list. Defaults to the configured hosts.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [string] $Name,
        [AllowNull()] [object[]] $Hosts
    )

    if ($null -eq $Hosts) { $Hosts = Get-WtwHosts }
    if ($Hosts.Count -eq 0) { return $null }

    $exact = $Hosts | Where-Object { $_.Name -ieq $Name } | Select-Object -First 1
    if ($exact) { return $exact }

    $byAlias = $Hosts | Where-Object { @($_.Aliases) -contains $Name } | Select-Object -First 1
    if ($byAlias) { return $byAlias }

    $prefixed = @($Hosts | Where-Object {
            $_.Name.StartsWith($Name, [System.StringComparison]::OrdinalIgnoreCase) -or
            (@($_.Aliases) | Where-Object { $_.StartsWith($Name, [System.StringComparison]::OrdinalIgnoreCase) })
        })
    if ($prefixed.Count -eq 1) { return $prefixed[0] }

    return $null
}

function Get-WtwLocalMachineName {
    <#
    .SYNOPSIS
        This machine's own names, for spotting a --on that points at itself.
    .DESCRIPTION
        Returns the OS hostname, its first DNS label, and the tailnet name when
        Tailscale is present — the same forms a host entry is likely to use.
    #>

    [CmdletBinding()]
    param()

    $names = @()
    try { $names += [System.Net.Dns]::GetHostName() } catch { Write-Verbose "hostname: $_" }
    if ($env:COMPUTERNAME) { $names += $env:COMPUTERNAME }

    try {
        # Assign before piping: the function returns `,@(...)`, so piping it
        # directly hands Where-Object the whole array as ONE item, `$_.IsSelf`
        # evaluates to an array of booleans, and every peer "matches".
        $peers = Get-WtwTailscalePeers
        $self = $peers | Where-Object { $_.IsSelf } | Select-Object -First 1
        if ($self) {
            $names += $self.Name
            if ($self.DnsName) { $names += $self.DnsName }
        }
    } catch { Write-Verbose "tailscale self: $_" }

    # First label too: 'box.tailnet.ts.net' should also answer to 'box'.
    $expanded = @($names | Where-Object { $_ } | ForEach-Object { $_; ($_ -split '\.')[0] })
    return , @($expanded | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique)
}

function Test-WtwIsLocalMachine {
    <#
    .SYNOPSIS
        Does this name refer to the machine we are already on?
    .DESCRIPTION
        `--on` names the OTHER machine. Pointing it at this one is a common
        direction mix-up when the same aliases exist on both sides, and without
        this check it surfaces as a bare "unknown host", which does not hint at
        the actual mistake.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] [string] $Name)

    return ((Get-WtwLocalMachineName) -contains $Name.ToLowerInvariant())
}

function Get-WtwHostTitlePrefix {
    <#
    .SYNOPSIS
        The "which machine am I on" prefix for a terminal title.
    .DESCRIPTION
        Produces `🧊AT.` for a host configured with emoji 🧊 and label AT, so a
        remote tab reads `🧊AT.🟢 PF037 …` — machine first, then the worktree's
        own title. With a dozen tabs open, the machine is the thing you need to
        recognise before anything else.

        The separator is configurable because the worktree half usually starts
        with its own emoji, and a '.' immediately before an emoji reads cramped.
        A space, or nothing at all, often looks better.

        The label defaults to the shortest alias upper-cased, then to the first
        two letters of the host name, so this is useful without configuring
        anything. An emoji is only shown when set; there is no default, because
        an arbitrary auto-assigned emoji per machine would be noise rather than
        identity.
    .PARAMETER HostEntry
        Host entry from Resolve-WtwHost.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)] $HostEntry)

    $emoji = Get-WtwPropertyValue -Object $HostEntry -Name 'Emoji'
    $label = Get-WtwPropertyValue -Object $HostEntry -Name 'Label'

    if (-not $label) {
        $aliases = @(Get-WtwPropertyValue -Object $HostEntry -Name 'Aliases' -DefaultValue @()) |
            Where-Object { $_ }
        $label = if ($aliases.Count -gt 0) {
            (@($aliases | Sort-Object { $_.Length }) | Select-Object -First 1).ToUpperInvariant()
        } else {
            $name = [string](Get-WtwPropertyValue -Object $HostEntry -Name 'Name')
            if ($name.Length -ge 2) { $name.Substring(0, 2).ToUpperInvariant() } else { $name.ToUpperInvariant() }
        }
    }

    $separator = Get-WtwPropertyValue -Object $HostEntry -Name 'Separator' -DefaultValue '.'
    return "$emoji$label$separator"
}

function Resolve-WtwHostVia {
    <#
    .SYNOPSIS
        Retarget a host at one transport, for a single command.
    .DESCRIPTION
        `wtw --on at list --via tailscale` is a one-off override — it must not
        touch the stored config the way `wtw host add --via` does.

        The trick is to return a host entry whose Name IS the chosen address.
        Everything downstream already keys off Name: ssh connects to it, and the
        editor authority becomes `ssh-remote+<address>`. Both work because
        Sync-WtwSshConfig also emits every candidate address as a Host pattern
        carrying the same User and IdentityFile.
    .PARAMETER HostEntry
        Entry from Resolve-WtwHost.
    .PARAMETER Via
        tailscale | zerotier | mdns | lan | any. Empty or 'any' returns the entry
        unchanged, so callers can pass through unconditionally.
    .OUTPUTS
        The retargeted entry, or $null when the host has no address of that kind.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $HostEntry,
        [AllowNull()] [string] $Via
    )

    if (-not $Via -or $Via -eq 'any') { return $HostEntry }

    $prefixes = Get-WtwZeroTierPrefixes
    $match = @(@($HostEntry.HostNames) |
            Where-Object { (Get-WtwAddressKind -Address $_ -ZeroTierPrefixes $prefixes) -eq $Via }) |
        Select-Object -First 1
    if (-not $match) { return $null }

    $clone = @{}
    foreach ($key in $HostEntry.Keys) { $clone[$key] = $HostEntry[$key] }
    $clone['Name'] = $match
    $clone['HostNames'] = @($match)
    $clone['ViaOverride'] = $Via
    return $clone
}

function Get-WtwHostNames {
    <#
    .SYNOPSIS
        Every name and alias that can address a configured host.
    .DESCRIPTION
        Used by the `--on`-less shorthand (`wtw at cursor auth`) and by tab
        completion, both of which need the flat token list rather than entries.
    #>

    [CmdletBinding()]
    param([AllowNull()] [object[]] $Hosts)

    if ($null -eq $Hosts) { $Hosts = Get-WtwHosts }
    $names = @()
    foreach ($h in $Hosts) {
        $names += $h.Name
        $names += @($h.Aliases)
    }
    return , @($names | Where-Object { $_ } | Select-Object -Unique)
}