feat(cli): show relay version in `portal list` output

Call GET /sdk/domain on each relay in parallel to fetch its release version, then print tab-separated URL and version. Shows "unknown" when a relay is unreachable. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 16, 2026 at 19:38 UTC 5fdfb4dff79994a42c32a509346670ad89002377
2 files changed +37 -2
cmd/portal-tunnel/main.go
+18 -2
@@ -7,6 +7,7 @@ import (
7 "fmt"
8 "io"
9 "os"
10 + "sync"
11 "time"
12
13 "github.com/rs/zerolog"
@@ -175,8 +176,23 @@ func runListCommand(args []string) error {
176 return errors.New("no relay URLs configured")
177 }
178
178 - for _, relayURL := range relayURLs {
179 - fmt.Println(relayURL)
179 + versions := make([]string, len(relayURLs))
180 + var wg sync.WaitGroup
181 + wg.Add(len(relayURLs))
182 + for i, u := range relayURLs {
183 + go func(idx int, url string) {
184 + defer wg.Done()
185 + versions[idx] = utils.FetchRelayVersion(ctx, url)
186 + }(i, u)
187 + }
188 + wg.Wait()
189 +
190 + for i, relayURL := range relayURLs {
191 + ver := versions[i]
192 + if ver == "" {
193 + ver = "unknown"
194 + }
195 + fmt.Printf("%s\t%s\n", relayURL, ver)
196 }
197 return nil
198 }
utils/network.go
+19
@@ -122,6 +122,25 @@ func SanitizeReportedIP(raw string) string {
122 return candidate
123 }
124
125 +// FetchRelayVersion calls GET /sdk/domain on a relay and returns its release version.
126 +// Returns an empty string on any error (timeout, unreachable, bad response).
127 +func FetchRelayVersion(ctx context.Context, relayURL string) string {
128 + client := &http.Client{Timeout: 3 * time.Second}
129 + resp, err := httpDo(ctx, client, http.MethodGet, relayURL+types.PathSDKDomain, nil, nil)
130 + if err != nil {
131 + return ""
132 + }
133 + defer resp.Body.Close()
134 + if resp.StatusCode != http.StatusOK {
135 + return ""
136 + }
137 + var envelope types.APIEnvelope[types.DomainResponse]
138 + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil || !envelope.OK {
139 + return ""
140 + }
141 + return envelope.Data.ReleaseVersion
142 +}
143 +
144 func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
145 explicit, err := NormalizeRelayURLs(explicit...)
146 if err != nil {