Hetzner.ps1

function New-Hetzner {
  param(

    [Parameter(Mandatory = $true)]
    [String] $name,
    [String] $type = "cx11" # cx32
  )

  if (-Not(Get-Command "hcloud.exe" -ErrorAction SilentlyContinue)) {
    Install-Scoop 
    scoop install hcloud
    hcloud completion powershell | Out-String | Invoke-Expression
  }

  #### Activate context

  $active = hcloud context active
  if (-Not($active)) {
    Write-Host "Active context not found"
    Write-Host "Visit the Hetzner Cloud Console at https://console.hetzner.cloud/, select your project, and create a new API token"
    $project = Read-Host -Prompt "Project"
    Write-Host "Copy and paste your token (it will not be showed) and press enter"
    hcloud context create $project
  }

  ##### Create server

  $servers = hcloud.exe server list -o json | ConvertFrom-Json
  $server = $servers | Where-Object { $_.name -eq $name }
  if (-Not($server)) {
    Write-Host "Creating server ${name}"
    $key_path = Get-SshKey -Public
    hcloud ssh-key create --name dev --public-key-from-file $key_path
    hcloud server create --name $name --image ubuntu-24.04 --type $type --ssh-key dev # -o json
  }

  ##### Config server

  $ip = hcloud server ip $name

  $command = "adduser box; usermod -aG sudo box; mkdir -p /home/box/.ssh; cp .ssh/authorized_keys /home/box/.ssh/authorized_keys; chown -R box:box /home/box/.ssh;"


  # TODO wait server responsive; pwsh ask password or ...
  ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR root@$ip $command | Write-Host

  ##### SSH

  # https://dev.to/kaiwalter/manage-ssh-config-entries-with-a-powershell-module-77b


  $sshKey = "${env:USERPROFILE}\.ssh\id_ed25519"
  $configFile = "${env:USERPROFILE}\.ssh\config"

  function Get-ConfigKeyWords {
    return @("Match",
      "AddressFamily",
      "BatchMode",
      "BindAddress",
      "ChallengeResponseAuthentication",
      "CheckHostIP",
      "Cipher",
      "Ciphers",
      "ClearAllForwardings",
      "Compression",
      "CompressionLevel",
      "ConnectionAttempts",
      "ConnectTimeout",
      "ControlMaster",
      "ControlPath",
      "DynamicForward",
      "EscapeChar",
      "ExitOnForwardFailure",
      "ForwardAgent",
      "ForwardX11",
      "ForwardX11Trusted",
      "GatewayPorts",
      "GlobalKnownHostsFile",
      "GSSAPIAuthentication",
      "GSSAPIKeyExchange",
      "GSSAPIClientIdentity",
      "GSSAPIDelegateCredentials",
      "GSSAPIRenewalForcesRekey",
      "GSSAPITrustDns",
      "HashKnownHosts",
      "HostbasedAuthentication",
      "HostKeyAlgorithms",
      "HostKeyAlias",
      "HostName",
      "IdentitiesOnly",
      "IdentityFile",
      "KbdInteractiveAuthentication",
      "KbdInteractiveDevices",
      "LocalCommand",
      "LocalForward",
      "LogLevel",
      "MACs",
      "NoHostAuthenticationForLocalhost",
      "PreferredAuthentications",
      "Protocol",
      "ProxyCommand",
      "PubkeyAuthentication",
      "RemoteForward",
      "RhostsRSAAuthentication",
      "RSAAuthentication",
      "SendEnv",
      "ServerAliveCountMax",
      "ServerAliveInterval",
      "SmartcardDevice",
      "StrictHostKeyChecking",
      "TCPKeepAlive",
      "Tunnel",
      "TunnelDevice",
      "UsePrivilegedPort",
      "User",
      "UserKnownHostsFile",
      "VerifyHostKeyDNS",
      "VisualHostKey")
  }

  function Get-ConfigHostList {

    $hostList = @{}

    if (-Not(Test-Path -Path $configFile)) {
      return $hostList
    }

    $contents = Get-Content $configFile -Raw

    # determine line break LF or CR/LF
    if ($contents -match "^[^\n]+\r\n") {
      $splitter = "\r\n"
    }
    else {
      $splitter = "\n"
    }

    # split by "Host" - when at start of file or has prededing line breaks / whitespaces
    $splitEntries = "(?i)(^|" + $splitter + "+\s+)host\s"
    $list = [regex]::Split($contents, $splitEntries)
    if ($list.Count -le 1) {
      throw "splitting file $configFilename failed or no content"
    }

    # READ lists of hosts

    foreach ($entry in $list) {
      # $output += $entry -replace $($splitter+"\s+"), $($joiner+" ")
      $attributes = [regex]::Split($entry, $splitter) | % { $_.Trim() }
      $HostName = $null
      $HostValues = @{}
      foreach ($attribute in $attributes) {
        if ($attribute -ne "") {
          if ($HostName) {
            # split key/value and normalize key name
            $kv = [regex]::Split($attribute, "\s+", 1)
            $keyName = $kv[0]
            $keyValue = $kv[1]
            foreach ($keyword in ($keywords | ? { $_ -eq $keyName })) {
              $keyName = $keyword                    
              break
            }
            $HostValues[$keyName] = $keyValue
          }
          else {
            # assume first entry to be the host
            $HostName = $attribute.ToLower()
          }
        }
      }
      if ($HostName) {
        if ($hostList.ContainsKey($HostName)) {
          throw "duplicate Host $HostName"
        }
        else {
          $hostList[$HostName] = $HostValues
        }
      }
    }

    return $hostList
  }

  function Add-ConfigHostToList {
    param (
      [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
      [hashtable]
      $hostList,
      [Parameter(Mandatory = $true)]
      [string]
      $HostName,
      [Parameter(Mandatory = $true)]
      [hashtable]
      $HostValues,
      [switch]
      $IgnoreExisting
    )

    if (!$IgnoreExisting) {
      if ($hostList.ContainsKey($HostName.ToLower())) {
        throw "HostName $HostName already exists"
      }
    }

    $keywords = Get-ConfigKeyWords
    $hostValuesCleaned = @{}
    foreach ($kv in $HostValues.GetEnumerator()) {
      $keyName = $null
      foreach ($keyword in ($keywords | ? { $_ -eq $kv.Key })) {
        $keyName = $keyword                    
        break
      }
      if ($keyName) {
        $hostValuesCleaned[$keyName] = $kv.Value.Trim()
      }
      else {
        throw "key $($kv.Key) not found in list of keywords" 
      }
    }
    if ($HostValuesCleaned) {
      $hostList[$HostName.ToLower()] = $HostValuesCleaned
    }

    return $hostList
  }

  function Set-ConfigContents {
    param (
      [Parameter(Mandatory = $true)]
      [string] $Contents
    )

    $configFilename = $configFile   
    if ($Contents) {
      $Contents | Set-Content $configFilename
    }
  }

  function Set-ConfigHostList {
    param (
      [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
      [hashtable] $hostList
    )

    $joiner = "`n"
    $output = @()

    foreach ($hostEntry in $hostList.GetEnumerator()) {
      $hostOutput = "Host " + $hostEntry.Key + $joiner
      foreach ($kv in $hostEntry.Value.GetEnumerator()) {
        $hostOutput = $hostOutput + " " + $kv.key + " " + $kv.value + $joiner
      }
      $output += $hostOutput
    }

    if ($output) {
      $content = $($output -join $($joiner))
      $content | Set-Content $configFile
    }
  }

  ##### VS Code

  Install-CodeExtension "ms-vscode-remote.remote-ssh"
  Install-CodeExtension "ms-vscode-remote.remote-wsl"

  $hostList = Get-ConfigHostList
  if (-Not($hostList.ContainsKey($name))) {
    $hostList = Add-ConfigHostToList -HostList $hostList -HostName $name -HostValues @{
      identityfile          = $sshKey
      hostname              = $ip
      user                  = "box"
      StrictHostKeyChecking = "no"
      UserKnownHostsFile    = "/dev/null"
    }
    Set-ConfigHostList $hostList
  }
  else {
    # TODO check IP
  }

  # https://code.visualstudio.com/docs/remote/troubleshooting#_connect-to-a-remote-host-from-the-terminal
  code --folder-uri "vscode-remote://ssh-remote+${name}/home/box"
}

function Install-HCloud {

  if (-Not(Get-Command "hcloud.exe" -ErrorAction SilentlyContinue)) { 
    scoop install hcloud
    hcloud completion powershell | Out-String | Invoke-Expression
  }
  
}

function Remove-Hetzner {
  param(
    [Parameter(Position = 0, mandatory = $true)]
    [string] $name
  )

  Install-HCloud
  hcloud server delete $name
}