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.