feat(cli): add cached update notification to expose and list commands

Check for new releases in the background when running `portal expose` or `portal list`. Results are cached locally for 24 hours to avoid repeated network requests. When a newer version is available, a hint is printed to stderr after the command output. - Cache stored at {UserCacheDir}/portal-tunnel/update_check.json - Atomic file writes prevent partial reads under concurrent access - Buffered goroutine channel with 500ms collection timeout - Eager print for expose (long-running), defer for list (short-lived) - Failures are silent — update checks never block command execution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Yechan Kim committed Apr 16, 2026 at 20:17 UTC a8a9f658372976142a87394694399c47dc9dac10
3 files changed +146
cmd/portal-tunnel/main.go
+6
@@ -58,6 +58,8 @@ type exposeFlags struct {
58 }
59
60 func runExposeCommand(args []string) error {
61 + updateCh := startUpdateCheck()
62 +
63 flags := exposeFlags{}
64 fs := utils.NewFlagSet("expose", printExposeUsage)
65
@@ -128,6 +130,7 @@ func runExposeCommand(args []string) error {
130 if err != nil {
131 return fmt.Errorf("failed to start relays: %w", err)
132 }
133 + printUpdateHint(updateCh)
134 if len(flags.httpRoutes) > 0 {
135 handler, err := newHTTPRouteHandler(flags.httpRoutes)
136 if err != nil {
@@ -146,6 +149,9 @@ type listFlags struct {
149 }
150
151 func runListCommand(args []string) error {
152 + updateCh := startUpdateCheck()
153 + defer printUpdateHint(updateCh)
154 +
155 flags := listFlags{}
156 fs := utils.NewFlagSet("list", printListUsage)
157
cmd/portal-tunnel/update_check.go new
+139
@@ -0,0 +1,139 @@
1 +package main
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "os"
7 + "path/filepath"
8 + "runtime"
9 + "time"
10 +
11 + "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
12 + "github.com/gosuda/portal-tunnel/v2/types"
13 +)
14 +
15 +const updateCheckTTL = 24 * time.Hour
16 +
17 +type updateCache struct {
18 + CheckedAt time.Time `json:"checked_at"`
19 + LatestVersion string `json:"latest_version"`
20 +}
21 +
22 +// startUpdateCheck begins a background goroutine that checks for a newer
23 +// release. It returns a buffered channel that will receive the latest version
24 +// string when a newer version exists, or an empty string otherwise. The check
25 +// is skipped when a valid cache entry exists within the TTL.
26 +func startUpdateCheck() <-chan string {
27 + ch := make(chan string, 1)
28 +
29 + slug := runtime.GOOS + "-" + runtime.GOARCH
30 + binURL, ok := installer.OfficialAssetURL(slug, false)
31 + if !ok {
32 + ch <- ""
33 + return ch
34 + }
35 +
36 + go func() {
37 + result := checkForUpdate(binURL)
38 + ch <- result
39 + }()
40 +
41 + return ch
42 +}
43 +
44 +func checkForUpdate(binURL string) string {
45 + cacheDir, err := updateCacheDir()
46 + if err != nil {
47 + return ""
48 + }
49 + cachePath := filepath.Join(cacheDir, "update_check.json")
50 +
51 + // Try reading the cache first.
52 + if cached, err := readUpdateCache(cachePath); err == nil {
53 + if time.Since(cached.CheckedAt) < updateCheckTTL {
54 + if cached.LatestVersion != types.ReleaseVersion {
55 + return cached.LatestVersion
56 + }
57 + return ""
58 + }
59 + }
60 +
61 + // Cache is missing or stale — check the network.
62 + latestVersion, err := detectLatestVersion(binURL)
63 + if err != nil {
64 + return ""
65 + }
66 +
67 + // Write cache atomically.
68 + writeUpdateCache(cachePath, updateCache{
69 + CheckedAt: time.Now(),
70 + LatestVersion: latestVersion,
71 + })
72 +
73 + if latestVersion != types.ReleaseVersion {
74 + return latestVersion
75 + }
76 + return ""
77 +}
78 +
79 +// printUpdateHint collects from the channel with a short timeout and prints
80 +// a hint to stderr if a newer version is available.
81 +func printUpdateHint(ch <-chan string) {
82 + if ch == nil {
83 + return
84 + }
85 + select {
86 + case version := <-ch:
87 + if version != "" {
88 + fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
89 + }
90 + case <-time.After(500 * time.Millisecond):
91 + }
92 +}
93 +
94 +func updateCacheDir() (string, error) {
95 + base, err := os.UserCacheDir()
96 + if err != nil {
97 + return "", err
98 + }
99 + dir := filepath.Join(base, "portal-tunnel")
100 + if err := os.MkdirAll(dir, 0755); err != nil {
101 + return "", err
102 + }
103 + return dir, nil
104 +}
105 +
106 +func readUpdateCache(path string) (updateCache, error) {
107 + data, err := os.ReadFile(path)
108 + if err != nil {
109 + return updateCache{}, err
110 + }
111 + var c updateCache
112 + if err := json.Unmarshal(data, &c); err != nil {
113 + return updateCache{}, err
114 + }
115 + return c, nil
116 +}
117 +
118 +func writeUpdateCache(path string, c updateCache) {
119 + data, err := json.Marshal(c)
120 + if err != nil {
121 + return
122 + }
123 +
124 + dir := filepath.Dir(path)
125 + tmp, err := os.CreateTemp(dir, ".update_check-*.json")
126 + if err != nil {
127 + return
128 + }
129 + tmpName := tmp.Name()
130 +
131 + if _, err := tmp.Write(data); err != nil {
132 + tmp.Close()
133 + os.Remove(tmpName)
134 + return
135 + }
136 + tmp.Close()
137 +
138 + os.Rename(tmpName, path)
139 +}
docs/src/routes/cli-reference/+page.md
+1
@@ -178,6 +178,7 @@ No flags. Outputs the version string (e.g., `v2.1.5`) and exits.
178
179 ## Behavior Notes
180
181 +- **Update notifications** - `portal expose` and `portal list` check for new releases in the background. The check runs at most once every 24 hours (cached locally) and never blocks command execution. When a newer version is found, a hint is printed to stderr after the command output.
182 - **Identity persistence** - `portal expose` loads or creates a signing identity at `identity.json` (or `--identity-path`). Reusing the same path keeps the same address across runs.
183 - **Multiple relays** - Multiple relay URLs are registered independently. Each relay gets its own lease. A relay going down does not stop healthy relays from serving.
184 - **Retry semantics** - Relay startup and reconnect failures are retried in the background. The tunnel starts as soon as relay URLs pass local validation.