diff --git a/cmd/aperture/main.go b/cmd/aperture/main.go index 5489706..cdc5327 100644 --- a/cmd/aperture/main.go +++ b/cmd/aperture/main.go @@ -12,6 +12,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/profiles" "github.com/tailscale/aperture-cli/internal/tui" @@ -19,6 +20,7 @@ import ( // Side-effect imports register each client with internal/clients. _ "github.com/tailscale/aperture-cli/internal/clients/claudecode" _ "github.com/tailscale/aperture-cli/internal/clients/codex" + _ "github.com/tailscale/aperture-cli/internal/clients/copilot" _ "github.com/tailscale/aperture-cli/internal/clients/gemini" _ "github.com/tailscale/aperture-cli/internal/clients/opencode" ) @@ -128,9 +130,19 @@ func main() { // Register Claude Desktop on supported platforms (darwin, windows). profiles.RegisterIfSupported() - p := tea.NewProgram(tui.NewModel(g, buildVersion)) + bridgeManager := bridges.NewManager(g.Debug) + p := tea.NewProgram(tui.NewModel(g, buildVersion, bridgeManager)) + + var exitCode int if _, err := p.Run(); err != nil { slog.Error("launcher error", "err", err) - os.Exit(1) + exitCode = 1 + } + if err := bridgeManager.Close(); err != nil { + slog.Error("shutting down bridges", "err", err) + exitCode = 1 + } + if exitCode != 0 { + os.Exit(exitCode) } } diff --git a/go.mod b/go.mod index 3a91b9f..00221c1 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,72 @@ module github.com/tailscale/aperture-cli -go 1.26 +go 1.26.2 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + tailscale.com v1.98.1 ) require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/akutz/memconn v0.1.0 // indirect + github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/coder/websocket v1.8.12 // indirect + github.com/creachadair/msync v0.7.1 // indirect + github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gaissmai/bart v0.26.1 // indirect + github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect + github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/hdevalence/ed25519consensus v0.2.0 // indirect + github.com/huin/goupnp v1.3.0 // indirect + github.com/jsimonetti/rtnetlink v1.4.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect + github.com/mdlayher/socket v0.5.0 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pires/go-proxyproto v0.8.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/safchain/ethtool v0.3.0 // indirect + github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd // indirect + github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect + github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect + github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.12.0 // indirect + golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect + golang.zx2c4.com/wireguard/windows v0.5.3 // indirect + gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect ) diff --git a/go.sum b/go.sum index b9c4cd0..6de9b86 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,268 @@ +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q= +9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= +filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= +github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k= +github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y= +github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= +github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ= +github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= -github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= +github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= +github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= +github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA= +github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= +github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= +github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= +github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= +github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= +github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8= +github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= +github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= +github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= +github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I= +github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= +github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= +github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I= +github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= +github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= +github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk= +github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= +github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= +github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= +github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= +github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= +github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= +github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= +github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= +github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= +github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= +github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= +github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= +github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= +github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= +github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= +github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= +github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE= +github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc= +github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= +github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= +github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= +github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= +github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= +github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= +github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= +github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs= +github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= +github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= +github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= +github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= +github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg= +github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= +github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= +go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= +golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= +golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= +golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= +golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA= +gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= +howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= +howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= +tailscale.com v1.98.1 h1:dEiQ3OqCzzcjTsP4m+CQTwz7ZdtdhxPbccY7AjUWno0= +tailscale.com v1.98.1/go.mod h1:6WwM2RnFW9gOQjdonp4c4QINm9odc1NlBQykKBncK/Q= diff --git a/internal/bridges/manager.go b/internal/bridges/manager.go new file mode 100644 index 0000000..1059cfe --- /dev/null +++ b/internal/bridges/manager.go @@ -0,0 +1,248 @@ +// Package bridges runs embedded tsnet reverse proxies for Aperture endpoints. +package bridges + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "sync" + + "github.com/tailscale/aperture-cli/internal/config" + "tailscale.com/tsnet" +) + +// Manager owns active tsnet nodes and localhost reverse proxies. +type Manager struct { + mu sync.Mutex + + debug bool + nodes map[string]*nodeRuntime + + newNode func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode +} + +type nodeRuntime struct { + node tailnetNode + proxies map[string]*proxyRuntime +} + +type proxyRuntime struct { + localURL string + server *http.Server + listener net.Listener +} + +type tailnetNode interface { + Up(context.Context) error + DialContext(context.Context, string, string) (net.Conn, error) + Close() error +} + +type tsnetNode struct { + server *tsnet.Server +} + +func (n *tsnetNode) Up(ctx context.Context) error { + _, err := n.server.Up(ctx) + return err +} + +func (n *tsnetNode) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return n.server.Dial(ctx, network, address) +} + +func (n *tsnetNode) Close() error { + return n.server.Close() +} + +// NewManager returns a bridge manager. When debug is true, verbose tsnet +// backend logs are also emitted to the supplied activation log sink. +func NewManager(debug bool) *Manager { + m := &Manager{ + debug: debug, + nodes: make(map[string]*nodeRuntime), + } + m.newNode = func(bridge config.Bridge, stateDir string, userLogf, debugLogf func(string, ...any)) tailnetNode { + s := &tsnet.Server{ + Dir: stateDir, + Hostname: "aperture-cli-" + bridge.ID, + UserLogf: userLogf, + } + if debug { + s.Logf = debugLogf + } + return &tsnetNode{server: s} + } + return m +} + +// Activate starts or reuses a bridge reverse proxy for remoteURL and returns +// the localhost URL clients should use. +func (m *Manager) Activate(ctx context.Context, bridge config.Bridge, remoteURL string, logf func(string)) (string, error) { + if m == nil { + return "", fmt.Errorf("bridge manager is not configured") + } + if err := validateBridgeID(bridge.ID); err != nil { + return "", err + } + if logf == nil { + logf = func(string) {} + } + target, err := parseTarget(remoteURL) + if err != nil { + return "", err + } + + m.mu.Lock() + rt := m.nodes[bridge.ID] + needUp := false + if rt == nil { + stateDir, err := config.BridgeStateDir(bridge.ID) + if err != nil { + m.mu.Unlock() + return "", err + } + userLogf := func(format string, args ...any) { + logf(fmt.Sprintf(format, args...)) + } + debugLogf := func(format string, args ...any) { + if m.debug { + logf(fmt.Sprintf(format, args...)) + } + } + node := m.newNode(bridge, stateDir, userLogf, debugLogf) + rt = &nodeRuntime{ + node: node, + proxies: make(map[string]*proxyRuntime), + } + m.nodes[bridge.ID] = rt + needUp = true + } + m.mu.Unlock() + + if needUp { + logf("Starting bridge " + bridge.Name + " (" + bridge.ID + ")") + if err := rt.node.Up(ctx); err != nil { + m.mu.Lock() + if m.nodes[bridge.ID] == rt { + delete(m.nodes, bridge.ID) + } + m.mu.Unlock() + return "", err + } + logf("Bridge connected.") + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.nodes[bridge.ID] != rt { + return "", fmt.Errorf("bridge stopped before activation completed") + } + key := target.String() + if proxy := rt.proxies[key]; proxy != nil { + return proxy.localURL, nil + } + + proxy, err := startProxy(rt.node, target) + if err != nil { + return "", err + } + rt.proxies[key] = proxy + logf("Listening on " + proxy.localURL) + return proxy.localURL, nil +} + +// Close shuts down all active reverse proxies and tsnet nodes. +func (m *Manager) Close() error { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + + var errs []error + for id, rt := range m.nodes { + for key, proxy := range rt.proxies { + if err := proxy.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errs = append(errs, err) + } + if err := proxy.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + errs = append(errs, err) + } + delete(rt.proxies, key) + } + if err := rt.node.Close(); err != nil { + errs = append(errs, err) + } + delete(m.nodes, id) + } + return errors.Join(errs...) +} + +// validateBridgeID rejects IDs that don't match the system-generated +// "bridge-" format, so a hand-edited config can't inject arbitrary +// content into the tailnet hostname. +func validateBridgeID(id string) error { + suffix, ok := strings.CutPrefix(id, "bridge-") + if !ok || suffix == "" { + return fmt.Errorf("invalid bridge ID %q", id) + } + if len(suffix) > 64 { + return fmt.Errorf("invalid bridge ID %q", id) + } + for _, r := range suffix { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return fmt.Errorf("invalid bridge ID %q", id) + } + } + return nil +} + +func parseTarget(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("endpoint URL is empty") + } + target, err := url.Parse(raw) + if err != nil { + return nil, err + } + if target.Scheme == "" || target.Host == "" { + return nil, fmt.Errorf("endpoint URL must include scheme and host") + } + return target, nil +} + +func startProxy(node tailnetNode, target *url.URL) (*proxyRuntime, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.DialContext = node.DialContext + + proxy := httputil.NewSingleHostReverseProxy(target) + director := proxy.Director + proxy.Director = func(req *http.Request) { + director(req) + req.Host = target.Host + } + proxy.Transport = transport + + srv := &http.Server{Handler: proxy} + go func() { + _ = srv.Serve(ln) + }() + + return &proxyRuntime{ + localURL: "http://" + ln.Addr().String(), + server: srv, + listener: ln, + }, nil +} diff --git a/internal/bridges/manager_test.go b/internal/bridges/manager_test.go new file mode 100644 index 0000000..7b0fd5c --- /dev/null +++ b/internal/bridges/manager_test.go @@ -0,0 +1,204 @@ +package bridges + +import ( + "context" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +type fakeNode struct { + backendAddr string + up int + closed bool +} + +func (n *fakeNode) Up(context.Context) error { + n.up++ + return nil +} + +func (n *fakeNode) DialContext(ctx context.Context, network, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, n.backendAddr) +} + +func (n *fakeNode) Close() error { + n.closed = true + return nil +} + +// activatedManager creates a Manager with a fake node wired to backend, +// activates the bridge once, and returns everything tests need. +type activatedFixture struct { + manager *Manager + node *fakeNode + localURL string + logs []string +} + +func activate(t *testing.T, backend *httptest.Server) activatedFixture { + t.Helper() + var f activatedFixture + f.manager = NewManager(false) + f.manager.newNode = func(_ config.Bridge, _ string, _ func(string, ...any), _ func(string, ...any)) tailnetNode { + f.node = &fakeNode{backendAddr: backend.Listener.Addr().String()} + return f.node + } + + var err error + f.localURL, err = f.manager.Activate( + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture.tailnet", + func(line string) { f.logs = append(f.logs, line) }, + ) + if err != nil { + t.Fatal(err) + } + return f +} + +func TestActivate(t *testing.T) { + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "proxies requests to backend", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`[{"id":"anthropic"}]`)) + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/api/providers") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if got := string(body); got != `[{"id":"anthropic"}]` { + t.Errorf("body = %s, want %s", got, `[{"id":"anthropic"}]`) + } + }, + }, + { + name: "rewrites Host header to target", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "aperture.tailnet" { + t.Errorf("Host = %q, want aperture.tailnet", r.Host) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "forwards request path", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/providers" { + t.Errorf("path = %q, want /api/providers", r.URL.Path) + } + })) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + resp, err := http.Get(f.localURL + "/api/providers") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + }, + }, + { + name: "returns localhost URL and calls Up once", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + if !strings.HasPrefix(f.localURL, "http://127.0.0.1:") { + t.Fatalf("localURL = %q, want http://127.0.0.1:... prefix", f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times, want 1", f.node.up) + } + if len(f.logs) == 0 { + t.Error("expected activation logs") + } + }, + }, + { + name: "reuses existing bridge without calling Up again", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + defer f.manager.Close() + + localURL2, err := f.manager.Activate( + context.Background(), + config.Bridge{ID: "bridge-abcdef", Name: "Work"}, + "http://aperture.tailnet", + nil, + ) + if err != nil { + t.Fatal(err) + } + if localURL2 != f.localURL { + t.Errorf("reused localURL = %q, want %q", localURL2, f.localURL) + } + if f.node.up != 1 { + t.Errorf("Up called %d times after reuse, want 1", f.node.up) + } + }, + }, + { + name: "Close shuts down node", + run: func(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer backend.Close() + + f := activate(t, backend) + + if err := f.manager.Close(); err != nil { + t.Fatal(err) + } + if !f.node.closed { + t.Error("node was not closed") + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, tc.run) + } +} diff --git a/internal/clients/copilot/copilot.go b/internal/clients/copilot/copilot.go new file mode 100644 index 0000000..07f5531 --- /dev/null +++ b/internal/clients/copilot/copilot.go @@ -0,0 +1,281 @@ +// Package copilot is the GitHub Copilot CLI client. It supports three backend +// flavors — OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages — +// and configures routing entirely via environment variables (no config files). +package copilot + +import ( + "os/exec" + "slices" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" + "github.com/tailscale/aperture-cli/internal/menu" +) + +func init() { + clients.Register(&Client{}) +} + +// Client is the GitHub Copilot CLI client. +type Client struct{} + +const ( + name = "GitHub Copilot" + binaryName = "copilot" +) + +type backend struct { + id string + displayName string + compatKey string + providerType string + wireAPI string +} + +var backends = []backend{ + {id: "openai_chat", displayName: "OpenAI Chat Completions", compatKey: "openai_chat", providerType: "openai", wireAPI: "completions"}, + {id: "openai_responses", displayName: "OpenAI Responses", compatKey: "openai_responses", providerType: "openai", wireAPI: "responses"}, + {id: "anthropic", displayName: "Anthropic Messages", compatKey: "anthropic_messages", providerType: "anthropic"}, +} + +// Name implements clients.Client. +func (c *Client) Name() string { return name } + +// BinaryName implements clients.Client. +func (c *Client) BinaryName() string { return binaryName } + +// CommonPaths implements clients.Client. +func (c *Client) CommonPaths() []string { return commonBinaryPaths() } + +// IsInstalled implements clients.Client. +func (c *Client) IsInstalled() bool { + return clients.IsInstalled(binaryName, c.CommonPaths()) +} + +// Install implements clients.Client. +func (c *Client) Install(_ *config.Global) clients.InstallPlan { + return clients.InstallPlan{ + Hint: "npm install -g @github/copilot", + Run: func() (*exec.Cmd, error) { + return exec.Command("/bin/sh", "-c", "npm install -g @github/copilot"), nil + }, + } +} + +// Uninstall implements clients.Client. +func (c *Client) Uninstall() clients.UninstallPlan { + return clients.UninstallPlan{ + Hint: "npm uninstall -g @github/copilot", + Run: func() error { + return exec.Command("npm", "uninstall", "-g", "@github/copilot").Run() + }, + } +} + +// Menu implements clients.Client. +func (c *Client) Menu(g *config.Global) menu.MenuItem { + return menu.MenuItem{ + Label: name, + Action: func() menu.Result { return c.providerStep(g) }, + } +} + +func (c *Client) providerStep(g *config.Global) menu.Result { + provs := compatibleProviders(g.Providers) + if len(provs) == 0 { + return errorResult("No providers support GitHub Copilot CLI.") + } + if len(provs) == 1 { + return c.backendStep(g, provs[0]) + } + items := make([]menu.MenuItem, 0, len(provs)) + for _, p := range provs { + items = append(items, menu.MenuItem{ + Label: p.DisplayName(), + Description: p.Description, + Action: func() menu.Result { return c.backendStep(g, p) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a provider for " + name + ":", + Items: items, + }} +} + +func (c *Client) backendStep(g *config.Global, p config.ProviderInfo) menu.Result { + bs := backendsFor(p) + if len(bs) == 0 { + return errorResult("No compatible backends for " + p.DisplayName() + ".") + } + if len(bs) == 1 { + return c.modelStep(g, p, bs[0]) + } + items := make([]menu.MenuItem, 0, len(bs)) + for _, b := range bs { + items = append(items, menu.MenuItem{ + Label: b.displayName, + Action: func() menu.Result { return c.modelStep(g, p, b) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a backend for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) modelStep(g *config.Global, p config.ProviderInfo, b backend) menu.Result { + models := fqnModels(p) + if len(models) <= 1 { + var m string + if len(models) == 1 { + m = models[0] + } + return c.launch(g, p, b, m) + } + items := make([]menu.MenuItem, 0, len(models)) + for _, m := range models { + items = append(items, menu.MenuItem{ + Label: m, + Action: func() menu.Result { return c.launch(g, p, b, m) }, + }) + } + return menu.Result{Next: &menu.Menu{ + Title: "Choose a default model for " + name + " via " + p.DisplayName() + ":", + Items: items, + }} +} + +func (c *Client) launch(g *config.Global, p config.ProviderInfo, b backend, model string) menu.Result { + bin := clients.FindBinary(binaryName, c.CommonPaths()) + if bin == "" { + bin = binaryName + } + + env := buildEnv(g.ApertureHost, b, model) + + _ = g.RecordLaunch(config.LaunchState{ + LastClientName: name, + LastBackendType: b.id, + LastProviderID: p.ID, + LastModel: model, + }) + + cmd := clients.Launch(clients.LaunchSpec{ + Binary: bin, + Env: env, + Debug: g.Debug, + }) + return menu.Result{Cmd: cmd, PopOnDone: true} +} + +func buildEnv(apertureHost string, b backend, model string) map[string]string { + host := strings.TrimRight(apertureHost, "/") + env := map[string]string{ + "COPILOT_PROVIDER_TYPE": b.providerType, + "COPILOT_PROVIDER_API_KEY": "not-needed", + "COPILOT_OFFLINE": "true", + } + if b.providerType == "openai" { + env["COPILOT_PROVIDER_BASE_URL"] = host + "/v1" + } else { + env["COPILOT_PROVIDER_BASE_URL"] = host + } + if b.wireAPI != "" { + env["COPILOT_PROVIDER_WIRE_API"] = b.wireAPI + } + if model != "" { + env["COPILOT_MODEL"] = stripProviderPrefix(model) + } + return env +} + +// Replay implements clients.Client. +func (c *Client) Replay(g *config.Global) tea.Cmd { + if g.LastLaunch.LastClientName != name || !c.IsInstalled() { + return nil + } + prov, ok := g.Provider(g.LastLaunch.LastProviderID) + if !ok { + return nil + } + idx := slices.IndexFunc(backends, func(b backend) bool { + return b.id == g.LastLaunch.LastBackendType + }) + if idx < 0 { + return nil + } + b := backends[idx] + if !prov.Compatibility[b.compatKey] { + return nil + } + model := g.LastLaunch.LastModel + if model != "" && !slices.Contains(fqnModels(prov), model) { + return nil + } + res := c.launch(g, prov, b, model) + return res.Cmd +} + +// QuickSelectLabel implements clients.Client. +func (c *Client) QuickSelectLabel(g *config.Global) string { + prov, _ := g.Provider(g.LastLaunch.LastProviderID) + b := g.LastLaunch.LastBackendType + for _, bb := range backends { + if bb.id == b { + b = bb.displayName + break + } + } + label := name + " via " + prov.DisplayName() + " - " + b + if g.LastLaunch.LastModel != "" { + label += " - " + g.LastLaunch.LastModel + } + return label +} + +func compatibleProviders(all []config.ProviderInfo) []config.ProviderInfo { + var out []config.ProviderInfo + for _, p := range all { + if len(backendsFor(p)) > 0 { + out = append(out, p) + } + } + return out +} + +func backendsFor(p config.ProviderInfo) []backend { + var out []backend + for _, b := range backends { + if p.Compatibility[b.compatKey] { + out = append(out, b) + } + } + return out +} + +func fqnModels(p config.ProviderInfo) []string { + out := make([]string, len(p.Models)) + for i, m := range p.Models { + out[i] = p.ID + "/" + m + } + return out +} + +func stripProviderPrefix(fqn string) string { + if _, after, ok := strings.Cut(fqn, "/"); ok { + return after + } + return fqn +} + +func errorResult(msg string) menu.Result { + return menu.Result{Cmd: func() tea.Msg { + return menu.SimpleDoneMsg{Err: errString(msg)} + }} +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/internal/clients/copilot/copilot_test.go b/internal/clients/copilot/copilot_test.go new file mode 100644 index 0000000..ec4f53f --- /dev/null +++ b/internal/clients/copilot/copilot_test.go @@ -0,0 +1,129 @@ +package copilot + +import ( + "testing" + + "github.com/tailscale/aperture-cli/internal/config" +) + +const testHost = "http://ai.example.com" + +func TestBuildEnv_OpenAIChat(t *testing.T) { + b := backends[0] // openai_chat + env := buildEnv(testHost, b, "prov/gpt-5") + + want := map[string]string{ + "COPILOT_PROVIDER_BASE_URL": testHost + "/v1", + "COPILOT_PROVIDER_TYPE": "openai", + "COPILOT_PROVIDER_API_KEY": "not-needed", + "COPILOT_OFFLINE": "true", + "COPILOT_PROVIDER_WIRE_API": "completions", + "COPILOT_MODEL": "gpt-5", + } + for k, v := range want { + if env[k] != v { + t.Errorf("%s = %q, want %q", k, env[k], v) + } + } + if len(env) != len(want) { + t.Errorf("env has %d keys, want %d", len(env), len(want)) + } +} + +func TestBuildEnv_OpenAIResponses(t *testing.T) { + b := backends[1] // openai_responses + env := buildEnv(testHost, b, "") + + if env["COPILOT_PROVIDER_WIRE_API"] != "responses" { + t.Errorf("WIRE_API = %q, want responses", env["COPILOT_PROVIDER_WIRE_API"]) + } + if _, ok := env["COPILOT_MODEL"]; ok { + t.Error("COPILOT_MODEL should not be set when model is empty") + } + if len(env) != 5 { + t.Errorf("env has %d keys, want 5", len(env)) + } +} + +func TestBuildEnv_Anthropic(t *testing.T) { + b := backends[2] // anthropic + env := buildEnv(testHost, b, "prov/claude-sonnet-4") + + if env["COPILOT_PROVIDER_BASE_URL"] != testHost { + t.Errorf("BASE_URL = %q, want %q (no /v1 for anthropic)", env["COPILOT_PROVIDER_BASE_URL"], testHost) + } + if _, ok := env["COPILOT_PROVIDER_WIRE_API"]; ok { + t.Error("WIRE_API should not be set for anthropic") + } + if env["COPILOT_MODEL"] != "claude-sonnet-4" { + t.Errorf("COPILOT_MODEL = %q, want claude-sonnet-4", env["COPILOT_MODEL"]) + } + if len(env) != 5 { + t.Errorf("env has %d keys, want 5", len(env)) + } +} + +func TestBackendsFor(t *testing.T) { + t.Run("openai_both", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{ + "openai_chat": true, + "openai_responses": true, + }} + bs := backendsFor(p) + if len(bs) != 2 { + t.Errorf("backendsFor = %d, want 2", len(bs)) + } + }) + t.Run("anthropic_only", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"anthropic_messages": true}} + bs := backendsFor(p) + if len(bs) != 1 || bs[0].id != "anthropic" { + t.Errorf("backendsFor = %+v, want [anthropic]", bs) + } + }) + t.Run("bedrock_none", func(t *testing.T) { + p := config.ProviderInfo{Compatibility: map[string]bool{"bedrock": true}} + bs := backendsFor(p) + if len(bs) != 0 { + t.Errorf("backendsFor = %+v, want empty", bs) + } + }) +} + +func TestCompatibleProviders(t *testing.T) { + provs := []config.ProviderInfo{ + {ID: "openai", Compatibility: map[string]bool{"openai_chat": true, "openai_responses": true}}, + {ID: "anthropic", Compatibility: map[string]bool{"anthropic_messages": true}}, + {ID: "bedrock", Compatibility: map[string]bool{"bedrock": true}}, + } + got := compatibleProviders(provs) + if len(got) != 2 { + t.Fatalf("compatibleProviders len = %d, want 2", len(got)) + } + if got[0].ID != "openai" || got[1].ID != "anthropic" { + t.Errorf("compatibleProviders = %v, want [openai, anthropic]", got) + } +} + +func TestFqnModels(t *testing.T) { + p := config.ProviderInfo{ID: "openai", Models: []string{"gpt-5", "gpt-5-mini"}} + got := fqnModels(p) + want := []string{"openai/gpt-5", "openai/gpt-5-mini"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("fqnModels = %v, want %v", got, want) + } +} + +func TestStripProviderPrefix(t *testing.T) { + cases := map[string]string{ + "openai/gpt-5": "gpt-5", + "anthropic/claude-sonnet-4": "claude-sonnet-4", + "bare-model": "bare-model", + "provider/nested/model": "nested/model", + } + for in, want := range cases { + if got := stripProviderPrefix(in); got != want { + t.Errorf("stripProviderPrefix(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/clients/copilot/install.go b/internal/clients/copilot/install.go new file mode 100644 index 0000000..d0d8417 --- /dev/null +++ b/internal/clients/copilot/install.go @@ -0,0 +1,17 @@ +package copilot + +import ( + "os" + "path/filepath" +) + +func commonBinaryPaths() []string { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + return []string{ + filepath.Join(home, ".local", "bin", "copilot"), + filepath.Join(home, ".npm-global", "bin", "copilot"), + } +} diff --git a/internal/config/global.go b/internal/config/global.go index 17a3b98..87585c7 100644 --- a/internal/config/global.go +++ b/internal/config/global.go @@ -1,5 +1,10 @@ package config +import ( + "fmt" + "strings" +) + // Global is the live mutable app-level state threaded through the TUI and // every client package. It holds the current Aperture endpoint, the user's // persisted settings, the last-launch record, and the provider list fetched @@ -53,13 +58,24 @@ func (g *Global) SetYolo(on bool) error { return SaveSettings(g.Settings) } -// SetApertureHost rotates the given URL to the front of the endpoint list -// (adding it if missing), updates ApertureHost, and persists. -func (g *Global) SetApertureHost(url string) error { - g.ApertureHost = url - eps := []Endpoint{{URL: url}} +// ActiveEndpoint returns the persisted endpoint currently selected by the +// user. The runtime ApertureHost may differ for bridge endpoints because it +// points at the local reverse proxy. +func (g *Global) ActiveEndpoint() Endpoint { + if len(g.Settings.Endpoints) == 0 { + return Endpoint{URL: DefaultLocation} + } + return g.Settings.Endpoints[0] +} + +// SetActiveEndpoint rotates the endpoint to the front of the endpoint list +// (adding it if missing), updates ApertureHost to the endpoint URL, and +// persists. Bridge activation later rewrites ApertureHost to localhost. +func (g *Global) SetActiveEndpoint(ep Endpoint) error { + g.ApertureHost = ep.URL + eps := []Endpoint{ep} for _, ep := range g.Settings.Endpoints { - if ep.URL != url { + if !sameEndpoint(ep, eps[0]) { eps = append(eps, ep) } } @@ -67,15 +83,21 @@ func (g *Global) SetApertureHost(url string) error { return SaveSettings(g.Settings) } -// UpsertEndpoint appends the URL to the endpoint list if not already present, +// SetApertureHost rotates the direct URL to the front of the endpoint list +// (adding it if missing), updates ApertureHost, and persists. +func (g *Global) SetApertureHost(url string) error { + return g.SetActiveEndpoint(Endpoint{URL: url}) +} + +// UpsertEndpoint appends the endpoint to the endpoint list if not already present, // without changing which endpoint is active, and persists. -func (g *Global) UpsertEndpoint(url string) error { - for _, ep := range g.Settings.Endpoints { - if ep.URL == url { +func (g *Global) UpsertEndpoint(ep Endpoint) error { + for _, existing := range g.Settings.Endpoints { + if sameEndpoint(existing, ep) { return nil } } - g.Settings.Endpoints = append(g.Settings.Endpoints, Endpoint{URL: url}) + g.Settings.Endpoints = append(g.Settings.Endpoints, ep) return SaveSettings(g.Settings) } @@ -96,6 +118,51 @@ func (g *Global) RemoveEndpoint(idx int) error { return SaveSettings(g.Settings) } +// AddBridge creates, saves, and returns a bridge with a generated stable ID. +func (g *Global) AddBridge(name string) (Bridge, error) { + name = strings.TrimSpace(name) + if name == "" { + return Bridge{}, fmt.Errorf("bridge name is empty") + } + id, err := newBridgeID(g.Settings.Bridges) + if err != nil { + return Bridge{}, err + } + p := Bridge{ID: id, Name: name} + g.Settings.Bridges = append(g.Settings.Bridges, p) + if err := SaveSettings(g.Settings); err != nil { + return Bridge{}, err + } + return p, nil +} + +// RemoveBridge deletes a bridge if no endpoint still references it. +func (g *Global) RemoveBridge(id string) error { + for _, ep := range g.Settings.Endpoints { + if ep.BridgeID == id { + return fmt.Errorf("bridge is used by endpoint %s", ep.URL) + } + } + for i, p := range g.Settings.Bridges { + if p.ID != id { + continue + } + g.Settings.Bridges = append(g.Settings.Bridges[:i], g.Settings.Bridges[i+1:]...) + return SaveSettings(g.Settings) + } + return nil +} + +// Bridge returns the configured bridge with id. +func (g *Global) Bridge(id string) (Bridge, bool) { + for _, p := range g.Settings.Bridges { + if p.ID == id { + return p, true + } + } + return Bridge{}, false +} + // RecordLaunch stores the launch record to disk and updates the in-memory copy. func (g *Global) RecordLaunch(s LaunchState) error { g.LastLaunch = s diff --git a/internal/config/settings.go b/internal/config/settings.go index 9b6700d..b113a55 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -5,9 +5,13 @@ package config import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "fmt" "os" "path/filepath" + "strings" ) // DefaultLocation is the fallback Aperture endpoint URL used when the user @@ -16,11 +20,22 @@ const DefaultLocation = "http://ai" // Endpoint holds the URL and per-endpoint configuration for an Aperture proxy. type Endpoint struct { - URL string `json:"url"` + URL string `json:"url"` + BridgeID string `json:"bridgeId,omitempty"` +} + +// Bridge is an embedded tsnet node used to reach Aperture without requiring +// Tailscale to run on the host. +type Bridge struct { + ID string `json:"id"` + Name string `json:"name"` } // Settings holds persistent launcher configuration managed by the user. type Settings struct { + // Bridges is the set of embedded tsnet nodes the user has configured. + Bridges []Bridge `json:"bridges,omitempty"` + // Endpoints is the ordered list of Aperture proxy endpoints. // The first entry is used as the active endpoint on startup. Endpoints []Endpoint `json:"endpoints,omitempty"` @@ -82,3 +97,41 @@ func defaultSettings() Settings { Endpoints: []Endpoint{{URL: DefaultLocation}}, } } + +// BridgeStateDir returns the tsnet state directory for a bridge ID. +func BridgeStateDir(id string) (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + suffix := strings.TrimPrefix(id, "bridge-") + if suffix == "" { + return "", fmt.Errorf("bridge ID is empty") + } + return filepath.Join(dir, "aperture", "bridges", suffix), nil +} + +func sameEndpoint(a, b Endpoint) bool { + return a.URL == b.URL && a.BridgeID == b.BridgeID +} + +func newBridgeID(existing []Bridge) (string, error) { + for range 10 { + var b [3]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + id := "bridge-" + hex.EncodeToString(b[:]) + found := false + for _, p := range existing { + if p.ID == id { + found = true + break + } + } + if !found { + return id, nil + } + } + return "", fmt.Errorf("could not generate a unique bridge ID") +} diff --git a/internal/config/state_test.go b/internal/config/state_test.go index 705a51b..298cca5 100644 --- a/internal/config/state_test.go +++ b/internal/config/state_test.go @@ -42,7 +42,11 @@ func TestLaunchState_LegacyMigration(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) // Seed a launcher.json in the old shape that used lastProfileName. - dir := filepath.Join(tmp, ".config", "aperture") + cfgDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(cfgDir, "aperture") if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatal(err) } @@ -75,9 +79,12 @@ func TestSettings_RoundTrip(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) want := config.Settings{ + Bridges: []config.Bridge{ + {ID: "bridge-abcdef", Name: "Work"}, + }, Endpoints: []config.Endpoint{ {URL: "http://ai"}, - {URL: "http://aperture.example.com"}, + {URL: "http://aperture.example.com", BridgeID: "bridge-abcdef"}, }, YoloMode: true, } @@ -92,6 +99,12 @@ func TestSettings_RoundTrip(t *testing.T) { if len(got.Endpoints) != 2 || got.Endpoints[0].URL != "http://ai" { t.Errorf("endpoints = %+v", got.Endpoints) } + if len(got.Bridges) != 1 || got.Bridges[0].ID != "bridge-abcdef" { + t.Errorf("bridges = %+v", got.Bridges) + } + if got.Endpoints[1].BridgeID != "bridge-abcdef" { + t.Errorf("bridge endpoint = %+v", got.Endpoints[1]) + } if !got.YoloMode { t.Error("YoloMode = false, want true") } @@ -125,6 +138,49 @@ func TestGlobal_SetApertureHost_RotatesToFront(t *testing.T) { } } +func TestGlobal_SetActiveEndpoint_DistinguishesBridge(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + g := &config.Global{ + Settings: config.Settings{ + Endpoints: []config.Endpoint{ + {URL: "http://ai"}, + {URL: "http://ai", BridgeID: "bridge-abcdef"}, + }, + }, + } + if err := g.SetActiveEndpoint(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}); err != nil { + t.Fatal(err) + } + if g.Settings.Endpoints[0].BridgeID != "bridge-abcdef" { + t.Errorf("front endpoint = %+v, want bridge endpoint", g.Settings.Endpoints[0]) + } + if len(g.Settings.Endpoints) != 2 { + t.Errorf("endpoints len = %d, want 2", len(g.Settings.Endpoints)) + } +} + +func TestBridgeStateDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("HOME", tmp) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, ".config")) + + got, err := config.BridgeStateDir("bridge-abcdef") + if err != nil { + t.Fatal(err) + } + cfgDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(cfgDir, "aperture", "bridges", "abcdef") + if got != want { + t.Errorf("BridgeStateDir = %q, want %q", got, want) + } +} + func TestClientConfig_TypedStore(t *testing.T) { tmp := t.TempDir() t.Setenv("HOME", tmp) diff --git a/internal/menu/menu.go b/internal/menu/menu.go index 3d1b135..9015cb2 100644 --- a/internal/menu/menu.go +++ b/internal/menu/menu.go @@ -49,8 +49,12 @@ type MenuItem struct { // Menu is a list of selectable items plus optional title and footer hint. type Menu struct { Title string - Items []MenuItem - Hint string + // Preamble is optional static text rendered (dimmed) between the title + // and the item list. Use it for informational paragraphs that are not + // selectable. + Preamble string + Items []MenuItem + Hint string // OnBack, when non-nil, overrides the default "pop stack one level" // behavior on Esc. Returning a nil tea.Cmd simply stays on this menu. OnBack func() tea.Cmd diff --git a/internal/tui/menus.go b/internal/tui/menus.go index dda0b79..280d431 100644 --- a/internal/tui/menus.go +++ b/internal/tui/menus.go @@ -8,12 +8,14 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/tailscale/aperture-cli/internal/clients" + "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" ) const ( - rootTitle = "Which editor do you want to use?" - endpointsTitle = "Aperture Endpoints" + rootTitle = "Which editor do you want to use?" + endpointsTitle = "Aperture Endpoints" + setupGuideTitle = "Getting Started" ) // rootMenu is the top-level client picker. It shows installed clients in @@ -105,6 +107,10 @@ func (m *model) settingsMenu() *menu.Menu { return &menu.Menu{ Title: "Settings", Items: []menu.MenuItem{ + { + Label: "Bridges", + Action: func() menu.Result { return menu.Result{Next: m.bridgesMenu()} }, + }, { Label: "Aperture Endpoints", Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, @@ -125,43 +131,84 @@ func (m *model) settingsMenu() *menu.Menu { } } +func (m *model) bridgesMenu() *menu.Menu { + items := []menu.MenuItem{ + { + Label: "Bridges connect Aperture through an embedded Tailscale node, so this host does not need tailscaled running.", + Disabled: true, + }, + } + for _, p := range m.g.Settings.Bridges { + p := p + items = append(items, menu.MenuItem{ + Label: p.Name, + Description: p.ID, + Action: func() menu.Result { return menu.Result{} }, + }) + } + items = append(items, menu.MenuItem{ + Label: "add", + Shortcut: "a", + Hidden: true, + Action: func() menu.Result { + m.promptForInput("Add Bridge:", "Name", func(v string) tea.Cmd { + if _, err := m.g.AddBridge(v); err != nil { + return func() tea.Msg { return menu.SimpleDoneMsg{Err: err} } + } + m.refreshBridgesMenu() + return nil + }) + return menu.Result{} + }, + }) + items = append(items, menu.MenuItem{ + Label: "delete", + Shortcut: "d", + Hidden: true, + Action: func() menu.Result { + idx := m.cursor() - 1 + if idx < 0 || idx >= len(m.g.Settings.Bridges) { + return menu.Result{} + } + if err := m.g.RemoveBridge(m.g.Settings.Bridges[idx].ID); err != nil { + return errResult(err.Error()) + } + return menu.Result{Replace: m.bridgesMenu()} + }, + }) + return &menu.Menu{ + Title: "Bridges", + Items: items, + Hint: "d to remove · a to add · Esc to go back", + } +} + // endpointsMenu lists configured endpoints with add/delete affordances. // Selecting an entry rotates it to the front and re-runs preflight. func (m *model) endpointsMenu() *menu.Menu { items := make([]menu.MenuItem, 0, len(m.g.Settings.Endpoints)+3) for i, ep := range m.g.Settings.Endpoints { - url := ep.URL - label := url + ep := ep + label := m.endpointLabel(ep) if i == 0 { - label = greenStyle.Render(url + " (active)") + label = greenStyle.Render(label + " (active)") } items = append(items, menu.MenuItem{ Label: label, Action: func() menu.Result { - if err := m.g.SetApertureHost(url); err != nil { + if err := m.g.SetActiveEndpoint(ep); err != nil { return errResult(err.Error()) } - m.step = stepPreflight - return menu.Result{Cmd: runPreflight(url)} + return menu.Result{Cmd: m.activateEndpointCmd(ep)} }, }) } - // Hidden: "a" prompts for a new endpoint. Surfaced via the footer hint. + // Hidden: "a" opens the endpoint connection flow. Surfaced via the footer hint. items = append(items, menu.MenuItem{ Label: "add", Shortcut: "a", Hidden: true, - Action: func() menu.Result { - m.promptForInput("Add Endpoint:", "", func(v string) tea.Cmd { - _ = m.g.UpsertEndpoint(v) - if len(m.stack) > 0 { - m.stack[len(m.stack)-1] = m.endpointsMenu() - m.cursors[len(m.cursors)-1] = 0 - } - return nil - }) - return menu.Result{} - }, + Action: func() menu.Result { return menu.Result{Next: m.addEndpointConnectionMenu()} }, }) // Hidden: "d" deletes the row under the cursor. items = append(items, menu.MenuItem{ @@ -178,18 +225,16 @@ func (m *model) endpointsMenu() *menu.Menu { }, }) - backHint := "Esc to go back" - if m.forcedToEndpoint { - backHint = "Esc to quit" - } - return &menu.Menu{ Title: endpointsTitle, Items: items, - Hint: "Enter to select · d to remove · a to add · " + backHint, + Hint: "Enter to select · d to remove · a to add · Esc to go back", OnBack: func() tea.Cmd { - if m.forcedToEndpoint { - return tea.Quit + if len(m.stack) <= 1 { + if m.forcedToEndpoint { + return m.quitCmd() + } + return nil } m.popOne() return tea.ClearScreen @@ -197,6 +242,131 @@ func (m *model) endpointsMenu() *menu.Menu { } } +// setupGuideMenu is shown when the preflight check fails. It diagnoses +// the user's Tailscale status and provides actionable guidance. +func (m *model) setupGuideMenu() *menu.Menu { + ts := checkTailscale() + + var preamble string + switch ts { + case tsNotInstalled: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is not installed.\nInstall it from: https://tailscale.com/download" + case tsNotRunning: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is installed but not running.\nStart Tailscale, then retry." + case tsNotConnected: + preamble = "Aperture connects to your AI providers through Tailscale.\n\nTailscale is not connected to a network.\nLog in with: tailscale up" + case tsConnected: + preamble = "Tailscale is connected.\n\nCould not reach Aperture at " + m.g.ApertureHost + ".\nEither:\n - set up an Aperture instance at https://aperture.tailscale.com/\n - or enter a different Aperture URL below" + } + + return &menu.Menu{ + Title: setupGuideTitle, + Preamble: preamble, + Items: []menu.MenuItem{ + { + Label: "Enter Aperture URL", + Action: func() menu.Result { + m.promptForInput("Aperture URL", "e.g. http://ai.example.com", func(v string) tea.Cmd { + v = strings.TrimSpace(v) + if !strings.Contains(v, "://") { + v = "http://" + v + } + _ = m.g.SetApertureHost(v) + return m.activateEndpointCmd(m.g.ActiveEndpoint()) + }) + return menu.Result{} + }, + }, + { + Label: "Retry connection", + Action: func() menu.Result { + return menu.Result{Cmd: m.activateEndpointCmd(m.g.ActiveEndpoint())} + }, + }, + { + Label: "Connection options", + Action: func() menu.Result { return menu.Result{Next: m.endpointsMenu()} }, + }, + }, + Hint: "Enter to select · Esc to quit", + OnBack: func() tea.Cmd { return m.quitCmd() }, + } +} + +func (m *model) addEndpointConnectionMenu() *menu.Menu { + return &menu.Menu{ + Title: "Endpoint Connection", + Items: []menu.MenuItem{ + { + Label: "Direct", + Action: func() menu.Result { + m.promptForInput("Add Direct Endpoint:", "URL", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v)}) + m.refreshEndpointsMenu() + return nil + }) + return menu.Result{} + }, + }, + { + Label: "Bridge", + Action: func() menu.Result { return menu.Result{Next: m.endpointBridgeMenu()} }, + }, + }, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) endpointBridgeMenu() *menu.Menu { + if len(m.g.Settings.Bridges) == 0 { + return &menu.Menu{ + Title: "Choose a bridge", + Items: []menu.MenuItem{ + { + Label: "No bridges configured.", + Disabled: true, + }, + { + Label: "Add Bridge", + Action: func() menu.Result { return menu.Result{Next: m.bridgesMenu()} }, + }, + }, + Hint: "Enter to add a bridge · Esc to go back", + } + } + items := make([]menu.MenuItem, 0, len(m.g.Settings.Bridges)) + for _, p := range m.g.Settings.Bridges { + p := p + items = append(items, menu.MenuItem{ + Label: p.Name, + Description: p.ID, + Action: func() menu.Result { + m.promptForInput("Add Bridge Endpoint:", "URL", func(v string) tea.Cmd { + _ = m.g.UpsertEndpoint(config.Endpoint{URL: strings.TrimSpace(v), BridgeID: p.ID}) + m.refreshEndpointsMenu() + return nil + }) + return menu.Result{} + }, + }) + } + return &menu.Menu{ + Title: "Choose a bridge", + Items: items, + Hint: "Enter to select · Esc to go back", + } +} + +func (m *model) endpointLabel(ep config.Endpoint) string { + if ep.BridgeID == "" { + return ep.URL + " (direct)" + } + if p, ok := m.g.Bridge(ep.BridgeID); ok { + return ep.URL + " via " + p.Name + } + return ep.URL + " via " + ep.BridgeID +} + // installAgentsMenu lists uninstalled clients and confirms/runs each install. func (m *model) installAgentsMenu() *menu.Menu { var items []menu.MenuItem diff --git a/internal/tui/tailscale.go b/internal/tui/tailscale.go new file mode 100644 index 0000000..47dea11 --- /dev/null +++ b/internal/tui/tailscale.go @@ -0,0 +1,66 @@ +package tui + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "runtime" + "time" +) + +type tailscaleStatus int + +const ( + tsNotInstalled tailscaleStatus = iota + tsNotRunning + tsNotConnected + tsConnected +) + +// checkTailscale probes the local Tailscale installation. Overridable for tests. +var checkTailscale = defaultCheckTailscale + +func defaultCheckTailscale() tailscaleStatus { + bin := findTailscaleBinary() + if bin == "" { + return tsNotInstalled + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "status", "--json").Output() + if err != nil { + return tsNotRunning + } + return parseTailscaleStatus(out) +} + +func findTailscaleBinary() string { + if p, err := exec.LookPath("tailscale"); err == nil { + return p + } + if runtime.GOOS == "darwin" { + const macApp = "/Applications/Tailscale.app/Contents/MacOS/Tailscale" + if _, err := os.Stat(macApp); err == nil { + return macApp + } + } + return "" +} + +func parseTailscaleStatus(data []byte) tailscaleStatus { + var status struct { + BackendState string `json:"BackendState"` + } + if err := json.Unmarshal(data, &status); err != nil { + return tsNotRunning + } + switch status.BackendState { + case "Running": + return tsConnected + case "NeedsLogin", "NeedsMachineAuth": + return tsNotConnected + default: + return tsNotRunning + } +} diff --git a/internal/tui/tailscale_test.go b/internal/tui/tailscale_test.go new file mode 100644 index 0000000..a3c83c2 --- /dev/null +++ b/internal/tui/tailscale_test.go @@ -0,0 +1,68 @@ +package tui + +import "testing" + +var statusName = map[tailscaleStatus]string{ + tsNotInstalled: "tsNotInstalled", + tsNotRunning: "tsNotRunning", + tsNotConnected: "tsNotConnected", + tsConnected: "tsConnected", +} + +func TestParseTailscaleStatus(t *testing.T) { + tests := []struct { + name string + input []byte + want tailscaleStatus + }{ + { + name: "Running", + input: []byte(`{"BackendState":"Running"}`), + want: tsConnected, + }, + { + name: "NeedsLogin", + input: []byte(`{"BackendState":"NeedsLogin"}`), + want: tsNotConnected, + }, + { + name: "NeedsMachineAuth", + input: []byte(`{"BackendState":"NeedsMachineAuth"}`), + want: tsNotConnected, + }, + { + name: "Stopped", + input: []byte(`{"BackendState":"Stopped"}`), + want: tsNotRunning, + }, + { + name: "Empty", + input: []byte(`{}`), + want: tsNotRunning, + }, + { + name: "InvalidJSON", + input: []byte(`not json`), + want: tsNotRunning, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := parseTailscaleStatus(tc.input) + if got != tc.want { + t.Errorf("got %s (%d), want %s (%d)", + statusName[got], got, statusName[tc.want], tc.want) + } + }) + } +} + +func TestStatusName(t *testing.T) { + // Verify the map covers all known constants. + for _, s := range []tailscaleStatus{tsNotInstalled, tsNotRunning, tsNotConnected, tsConnected} { + if _, ok := statusName[s]; !ok { + t.Errorf("statusName missing entry for %d", s) + } + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9267b7c..bf6e0c9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ package tui import ( + "context" "encoding/json" "fmt" "io" @@ -16,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/tailscale/aperture-cli/internal/bridges" "github.com/tailscale/aperture-cli/internal/clients" "github.com/tailscale/aperture-cli/internal/config" "github.com/tailscale/aperture-cli/internal/menu" @@ -45,17 +47,19 @@ var ( // NewModel returns the TUI model. g holds the persisted launcher state // (settings, endpoints, last launch). buildVersion is shown at the bottom // of the client picker. -func NewModel(g *config.Global, buildVersion string) tea.Model { +func NewModel(g *config.Global, buildVersion string, bridgeManager *bridges.Manager) tea.Model { return &model{ - g: g, - buildVersion: buildVersion, - step: stepPreflight, + g: g, + buildVersion: buildVersion, + bridgeManager: bridgeManager, + step: stepPreflight, } } type model struct { - g *config.Global - buildVersion string + g *config.Global + buildVersion string + bridgeManager *bridges.Manager step step @@ -81,10 +85,14 @@ type model struct { // Preflight state. preflightErr string forcedToEndpoint bool // true when preflight failure dropped user on endpoints menu + preflightLabel string + bridgeLogCh chan string + bridgeLogs []string + bridgeCancel context.CancelFunc } func (m *model) Init() tea.Cmd { - return runPreflight(m.g.ApertureHost) + return m.activateEndpointCmd(m.g.ActiveEndpoint()) } // preflightResult is emitted when the /api/providers check completes. @@ -94,30 +102,133 @@ type preflightResult struct { err error } +type endpointActivationResult struct { + endpoint config.Endpoint + host string + providers []config.ProviderInfo + err error +} + +type bridgeLogMsg string +type bridgeLogDoneMsg struct{} +type quitMsg struct{ Err error } + func runPreflight(host string) tea.Cmd { return func() tea.Msg { - client := &http.Client{Timeout: 10 * time.Second} - url := strings.TrimRight(host, "/") + "/api/providers" - resp, err := client.Get(url) - if err != nil { - return preflightResult{host: host, err: err} + provs, err := fetchProviders(host) + return preflightResult{host: host, providers: provs, err: err} + } +} + +func fetchProviders(host string) ([]config.ProviderInfo, error) { + client := &http.Client{Timeout: 10 * time.Second} + url := strings.TrimRight(host, "/") + "/api/providers" + resp, err := client.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var provs []config.ProviderInfo + if err := json.Unmarshal(body, &provs); err != nil { + return nil, fmt.Errorf("could not parse providers response: %w", err) + } + return provs, nil +} + +func (m *model) activateEndpointCmd(ep config.Endpoint) tea.Cmd { + m.step = stepPreflight + m.preflightErr = "" + m.bridgeLogs = nil + m.bridgeLogCh = nil + if m.bridgeCancel != nil { + m.bridgeCancel() + m.bridgeCancel = nil + } + + if ep.BridgeID == "" { + m.preflightLabel = "Checking " + ep.URL + " ..." + return func() tea.Msg { + provs, err := fetchProviders(ep.URL) + return endpointActivationResult{endpoint: ep, host: ep.URL, providers: provs, err: err} } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return preflightResult{ - host: host, - err: fmt.Errorf("unexpected status %d from %s", resp.StatusCode, url), + } + + bridge, ok := m.g.Bridge(ep.BridgeID) + if !ok { + m.preflightLabel = "Checking " + ep.URL + " ..." + return func() tea.Msg { + return endpointActivationResult{ + endpoint: ep, + host: ep.URL, + err: fmt.Errorf("bridge %s is not configured", ep.BridgeID), + } + } + } + if m.bridgeManager == nil { + return func() tea.Msg { + return endpointActivationResult{ + endpoint: ep, + host: ep.URL, + err: fmt.Errorf("bridge manager is not configured"), } } - body, err := io.ReadAll(resp.Body) + } + + ch := make(chan string, 32) + ctx, cancel := context.WithCancel(context.Background()) + m.bridgeLogCh = ch + m.bridgeCancel = cancel + m.preflightLabel = "Connecting bridge " + bridge.Name + " to " + ep.URL + " ..." + activate := func() tea.Msg { + defer cancel() + defer close(ch) + localURL, err := m.bridgeManager.Activate(ctx, bridge, ep.URL, func(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + select { + case ch <- line: + default: + } + }) if err != nil { - return preflightResult{host: host, err: err} + return endpointActivationResult{endpoint: ep, host: ep.URL, err: err} + } + provs, err := fetchProviders(localURL) + return endpointActivationResult{endpoint: ep, host: localURL, providers: provs, err: err} + } + return tea.Batch(activate, waitBridgeLog(ch)) +} + +func waitBridgeLog(ch <-chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return bridgeLogDoneMsg{} + } + return bridgeLogMsg(line) + } +} + +func (m *model) quitCmd() tea.Cmd { + cancel := m.bridgeCancel + bridgeManager := m.bridgeManager + return func() tea.Msg { + if cancel != nil { + cancel() } - var provs []config.ProviderInfo - if err := json.Unmarshal(body, &provs); err != nil { - return preflightResult{host: host, err: fmt.Errorf("could not parse providers response: %w", err)} + if bridgeManager == nil { + return quitMsg{} } - return preflightResult{host: host, providers: provs} + return quitMsg{Err: bridgeManager.Close()} } } @@ -133,24 +244,63 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.preflightErr = msg.err.Error() m.forcedToEndpoint = true m.step = stepMenu - m.resetStack(m.endpointsMenu()) + m.resetStack(m.setupGuideMenu()) return m, nil } m.g.Providers = msg.providers m.preflightErr = "" m.forcedToEndpoint = false - // Ensure the active host is in the endpoint list and first. - _ = m.g.UpsertEndpoint(m.g.ApertureHost) m.step = stepMenu m.resetStack(m.rootMenu()) return m, tea.ClearScreen + case endpointActivationResult: + m.bridgeCancel = nil + if msg.err != nil { + m.preflightErr = msg.err.Error() + m.forcedToEndpoint = true + m.g.ApertureHost = msg.endpoint.URL + m.step = stepMenu + m.resetStack(m.setupGuideMenu()) + return m, nil + } + m.g.ApertureHost = msg.host + m.g.Providers = msg.providers + m.preflightErr = "" + m.forcedToEndpoint = false + m.step = stepMenu + m.resetStack(m.rootMenu()) + return m, tea.ClearScreen + + case bridgeLogMsg: + m.bridgeLogs = append(m.bridgeLogs, string(msg)) + if len(m.bridgeLogs) > 12 { + m.bridgeLogs = m.bridgeLogs[len(m.bridgeLogs)-12:] + } + if m.bridgeLogCh != nil { + return m, waitBridgeLog(m.bridgeLogCh) + } + return m, nil + + case bridgeLogDoneMsg: + m.bridgeLogCh = nil + return m, nil + + case quitMsg: + if msg.Err != nil { + m.errMsg = "Error shutting down bridges: " + msg.Err.Error() + m.step = stepError + return m, nil + } + return m, tea.Quit + case menu.ExecDoneMsg: // A client's foreground launch has exited. Re-run preflight: the // user may have changed things outside the launcher while the // agent was running. m.popToRoot() m.step = stepPreflight + m.preflightLabel = "Checking " + m.g.ApertureHost + " ..." return m, runPreflight(m.g.ApertureHost) case menu.InstallDoneMsg: @@ -179,13 +329,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.step { case stepPreflight: if msg.String() == "ctrl+c" { - return m, tea.Quit + return m, m.quitCmd() } return m, nil case stepError: switch msg.String() { case "ctrl+c", "q": - return m, tea.Quit + return m, m.quitCmd() default: m.step = stepMenu return m, nil @@ -208,12 +358,12 @@ func (m *model) updateMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - return m, tea.Quit + return m, m.quitCmd() case "q": // "q" quits from the root only; on sub-menus it pops. if len(m.stack) <= 1 { - return m, tea.Quit + return m, m.quitCmd() } m.popOne() return m, tea.ClearScreen @@ -320,7 +470,7 @@ func (m *model) activate(idx int) (tea.Model, tea.Cmd) { func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { switch { case res.Quit: - return m, tea.Quit + return m, m.quitCmd() case res.Pop: m.popOne() return m, tea.ClearScreen @@ -346,7 +496,7 @@ func (m *model) applyResult(res menu.Result) (tea.Model, tea.Cmd) { func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "ctrl+c": - return m, tea.Quit + return m, m.quitCmd() case "esc": m.step = stepMenu m.inputValue = "" @@ -380,7 +530,17 @@ func (m *model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) View() string { switch m.step { case stepPreflight: - return dotYellow + " Checking " + m.g.ApertureHost + " …\n" + label := m.preflightLabel + if label == "" { + label = "Checking " + m.g.ApertureHost + " ..." + } + var sb strings.Builder + sb.WriteString(dotYellow + " " + label + "\n") + for _, line := range m.bridgeLogs { + sb.WriteString(dimStyle.Render(" " + line)) + sb.WriteString("\n") + } + return sb.String() case stepError: var sb strings.Builder sb.WriteString(errorStyle.Render("Cannot launch")) @@ -419,6 +579,13 @@ func (m *model) viewMenu() string { sb.WriteString(titleStyle.Render(top.Title)) sb.WriteString("\n") } + if top.Preamble != "" { + for _, line := range strings.Split(top.Preamble, "\n") { + sb.WriteString(dimStyle.Render(" " + line)) + sb.WriteString("\n") + } + sb.WriteString("\n") + } cursor := m.cursor() tokens := assignTokens(top.Items) visible, twoCols, half := m.menuLayout(top) @@ -609,9 +776,9 @@ func (m *model) menuHeader(top *menu.Menu) string { } return header + "\n\n" } - if m.forcedToEndpoint && top.Title == endpointsTitle { + if m.forcedToEndpoint && (top.Title == endpointsTitle || top.Title == setupGuideTitle) { header := dotRed + " Could not reach " + m.g.ApertureHost + "\n" - if m.preflightErr != "" { + if m.preflightErr != "" && top.Title != setupGuideTitle { header += dimStyle.Render(" "+m.preflightErr) + "\n" } return header + "\n" @@ -662,6 +829,39 @@ func (m *model) resetStack(root *menu.Menu) { m.cursors = []int{0} } +func (m *model) refreshEndpointsMenu() { + m.refreshMenuByTitle(endpointsTitle, m.endpointsMenu()) +} + +func (m *model) refreshBridgesMenu() { + for i := range m.stack { + if m.stack[i].Title == "Choose a bridge" { + m.stack[i] = m.endpointBridgeMenu() + m.cursors[i] = 0 + } + } + m.refreshMenuByTitle("Bridges", m.bridgesMenu()) +} + +func (m *model) refreshMenuByTitle(title string, next *menu.Menu) { + for i := len(m.stack) - 1; i >= 0; i-- { + if m.stack[i].Title != title { + continue + } + m.stack = m.stack[:i+1] + m.cursors = m.cursors[:i+1] + m.stack[i] = next + m.cursors[i] = 0 + return + } + if len(m.stack) > 0 { + m.stack[len(m.stack)-1] = next + m.cursors[len(m.cursors)-1] = 0 + return + } + m.resetStack(next) +} + // --- Input step helpers --- // promptForInput sets up the single-line text input step. onSave is invoked diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 6669897..62efca9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" @@ -216,15 +217,163 @@ func TestSettingsMenu_ToggleYolo(t *testing.T) { m := &model{g: g, step: stepMenu} m.resetStack(m.settingsMenu()) - // YOLO is the 3rd item. - res := m.top().Items[2].Action() + idx := -1 + for i, it := range m.top().Items { + if strings.HasPrefix(it.Label, "YOLO mode:") { + idx = i + break + } + } + if idx == -1 { + t.Fatal("YOLO item not found") + } + res := m.top().Items[idx].Action() if !g.Settings.YoloMode { t.Error("YoloMode = false after toggle") } if res.Replace == nil { t.Fatal("toggle should replace menu in place") } - if !strings.Contains(res.Replace.Items[2].Label, "YOLO mode: on") { - t.Errorf("new label = %q", res.Replace.Items[2].Label) + if !strings.Contains(res.Replace.Items[idx].Label, "YOLO mode: on") { + t.Errorf("new label = %q", res.Replace.Items[idx].Label) + } +} + +func TestSettingsMenu_BridgesFirst(t *testing.T) { + m := &model{g: &config.Global{}, step: stepMenu} + menu := m.settingsMenu() + if len(menu.Items) == 0 || menu.Items[0].Label != "Bridges" { + t.Fatalf("first settings item = %+v, want Bridges", menu.Items) + } +} + +// withFakeTailscale overrides checkTailscale for the duration of a test. +func withFakeTailscale(t *testing.T, status tailscaleStatus) { + t.Helper() + orig := checkTailscale + checkTailscale = func() tailscaleStatus { return status } + t.Cleanup(func() { checkTailscale = orig }) +} + +func TestSetupGuideMenu_TailscaleNotInstalled(t *testing.T) { + withFakeTailscale(t, tsNotInstalled) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + if guide.Title != setupGuideTitle { + t.Errorf("title = %q, want %q", guide.Title, setupGuideTitle) + } + if !strings.Contains(guide.Preamble, "tailscale.com/download") { + t.Error("preamble missing Tailscale download URL") + } + actionCount := 0 + for _, it := range guide.Items { + if it.Action != nil { + actionCount++ + } + } + if actionCount != 3 { + t.Errorf("actionable items = %d, want 3", actionCount) + } +} + +func TestSetupGuideMenu_TailscaleConnected(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + if !strings.Contains(guide.Preamble, "aperture.tailscale.com") { + t.Error("preamble missing Aperture provisioning URL") + } + if !strings.Contains(guide.Preamble, "Tailscale is connected") { + t.Error("preamble missing 'Tailscale is connected' message") + } +} + +func TestSetupGuideMenu_RetryAction(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + for _, it := range guide.Items { + if it.Label == "Retry connection" { + res := it.Action() + if res.Cmd == nil { + t.Error("Retry action returned nil Cmd") + } + return + } + } + t.Error("Retry connection item not found") +} + +func TestSetupGuideMenu_ConnectionOptionsAction(t *testing.T) { + withFakeTailscale(t, tsConnected) + m := &model{g: &config.Global{ApertureHost: "http://ai"}} + guide := m.setupGuideMenu() + + for _, it := range guide.Items { + if it.Label == "Connection options" { + res := it.Action() + if res.Next == nil || res.Next.Title != endpointsTitle { + t.Errorf("Connection options should push endpoints menu, got %+v", res.Next) + } + return + } + } + t.Error("Connection options item not found") +} + +func TestPreflightFailure_ShowsSetupGuide(t *testing.T) { + withFakeTailscale(t, tsNotInstalled) + withFakeClients(t, nil) + m := &model{ + g: &config.Global{ApertureHost: "http://ai"}, + step: stepPreflight, + } + m.Update(preflightResult{err: fmt.Errorf("connection refused")}) + if !m.forcedToEndpoint { + t.Error("forcedToEndpoint should be true") + } + if m.top() == nil || m.top().Title != setupGuideTitle { + title := "" + if m.top() != nil { + title = m.top().Title + } + t.Errorf("top menu title = %q, want %q", title, setupGuideTitle) + } +} + +func TestEndpointActivationFailure_ShowsSetupGuide(t *testing.T) { + withFakeTailscale(t, tsConnected) + withFakeClients(t, nil) + m := &model{ + g: &config.Global{ApertureHost: "http://ai"}, + } + m.Update(endpointActivationResult{ + endpoint: config.Endpoint{URL: "http://ai"}, + err: fmt.Errorf("timeout"), + }) + if !m.forcedToEndpoint { + t.Error("forcedToEndpoint should be true") + } + if m.top() == nil || m.top().Title != setupGuideTitle { + title := "" + if m.top() != nil { + title = m.top().Title + } + t.Errorf("top menu title = %q, want %q", title, setupGuideTitle) + } +} + +func TestEndpointLabel_ShowsBridge(t *testing.T) { + m := &model{g: &config.Global{ + Settings: config.Settings{ + Bridges: []config.Bridge{{ID: "bridge-abcdef", Name: "Work"}}, + }, + }} + got := m.endpointLabel(config.Endpoint{URL: "http://ai", BridgeID: "bridge-abcdef"}) + if got != "http://ai via Work" { + t.Errorf("endpointLabel = %q", got) } }