refactor(cli): move runUpdateCommand to main.go, merge update_check into update.go
Align with existing pattern where command handlers (runExposeCommand, runListCommand) live in main.go and implementation logic lives in separate files (relays.go, http_routes.go). - Move runUpdateCommand() to main.go alongside other command handlers - Merge update_check.go into update.go (all update-related logic) - Delete update_check.go - Fix strings.Fields()[0] panic on empty checksum response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Yechan Kim committed
Apr 16, 2026 at 21:13 UTC
18a5474dddaa5bc77bc51687d5e1a976bce89b4c
3 files changed
+178
-184
cmd/portal-tunnel/main.go
+66
@@ -7,11 +7,14 @@ import (
7
"fmt"
8
"io"
9
"os"
10
+ "path/filepath"
11
+ "runtime"
12
"time"
13
14
"github.com/rs/zerolog"
15
"github.com/rs/zerolog/log"
16
17
+ "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/installer"
18
"github.com/gosuda/portal-tunnel/v2/sdk"
19
"github.com/gosuda/portal-tunnel/v2/types"
20
"github.com/gosuda/portal-tunnel/v2/utils"
@@ -148,6 +151,69 @@ type listFlags struct {
151
defaultRelays bool
152
}
153
154
+func runUpdateCommand(args []string) error {
155
+ slug := runtime.GOOS + "-" + runtime.GOARCH
156
+ if _, ok := installer.AssetFilename(slug); !ok {
157
+ return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
158
+ }
159
+
160
+ execPath, err := os.Executable()
161
+ if err != nil {
162
+ return fmt.Errorf("failed to determine executable path: %w", err)
163
+ }
164
+ execPath, err = filepath.EvalSymlinks(execPath)
165
+ if err != nil {
166
+ return fmt.Errorf("failed to resolve executable path: %w", err)
167
+ }
168
+
169
+ // Pre-check: verify that the binary's directory is writable before downloading.
170
+ if err := checkWritable(filepath.Dir(execPath)); err != nil {
171
+ return fmt.Errorf("cannot update %s: %w", execPath, err)
172
+ }
173
+
174
+ binURL, _ := installer.OfficialAssetURL(slug, false)
175
+
176
+ latestVersion, err := detectLatestVersion(binURL)
177
+ if err != nil {
178
+ return fmt.Errorf("failed to detect latest version: %w", err)
179
+ }
180
+
181
+ if latestVersion == types.ReleaseVersion {
182
+ fmt.Fprintf(os.Stderr, "Already up to date (%s).\n", types.ReleaseVersion)
183
+ return nil
184
+ }
185
+
186
+ fmt.Fprintf(os.Stderr, "Updating %s → %s ...\n", types.ReleaseVersion, latestVersion)
187
+
188
+ tmpFile, err := os.CreateTemp("", "portal-update-*")
189
+ if err != nil {
190
+ return fmt.Errorf("failed to create temp file: %w", err)
191
+ }
192
+ defer func() { _ = os.Remove(tmpFile.Name()) }()
193
+
194
+ if err := downloadBinary(binURL, tmpFile); err != nil {
195
+ _ = tmpFile.Close()
196
+ return fmt.Errorf("failed to download binary: %w", err)
197
+ }
198
+ if err := tmpFile.Sync(); err != nil {
199
+ _ = tmpFile.Close()
200
+ return fmt.Errorf("failed to sync downloaded binary: %w", err)
201
+ }
202
+ _ = tmpFile.Close()
203
+
204
+ checksumURL, _ := installer.OfficialAssetURL(slug, true)
205
+ if err := verifyChecksum(tmpFile.Name(), checksumURL); err != nil {
206
+ return fmt.Errorf("checksum verification failed: %w", err)
207
+ }
208
+
209
+ if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
210
+ return fmt.Errorf("failed to replace binary: %w", err)
211
+ }
212
+
213
+ fmt.Fprintf(os.Stderr, "Updated %s → %s\n", types.ReleaseVersion, latestVersion)
214
+ return nil
215
+}
216
+
217
func runListCommand(args []string) error {
218
updateCh := startUpdateCheck()
219
defer printUpdateHint(updateCh)
cmd/portal-tunnel/update.go
+112
-45
@@ -4,6 +4,7 @@ import (
4
"context"
5
"crypto/sha256"
6
"encoding/hex"
7
+ "encoding/json"
8
"fmt"
9
"io"
10
"net/http"
@@ -18,67 +19,82 @@ import (
19
"github.com/gosuda/portal-tunnel/v2/types"
20
)
21
21
-func runUpdateCommand(args []string) error {
22
- slug := runtime.GOOS + "-" + runtime.GOARCH
23
- if _, ok := installer.AssetFilename(slug); !ok {
24
- return fmt.Errorf("unsupported platform: %s/%s", runtime.GOOS, runtime.GOARCH)
25
- }
22
+const updateCheckTTL = 24 * time.Hour
23
27
- execPath, err := os.Executable()
28
- if err != nil {
29
- return fmt.Errorf("failed to determine executable path: %w", err)
30
- }
31
- execPath, err = filepath.EvalSymlinks(execPath)
32
- if err != nil {
33
- return fmt.Errorf("failed to resolve executable path: %w", err)
34
- }
24
+type updateCache struct {
25
+ CheckedAt time.Time `json:"checked_at"`
26
+ LatestVersion string `json:"latest_version"`
27
+}
28
+
29
+// startUpdateCheck begins a background goroutine that checks for a newer
30
+// release. It returns a buffered channel that will receive the latest version
31
+// string when a newer version exists, or an empty string otherwise. The check
32
+// is skipped when a valid cache entry exists within the TTL.
33
+func startUpdateCheck() <-chan string {
34
+ ch := make(chan string, 1)
35
36
- // Pre-check: verify that the binary's directory is writable before downloading.
37
- if err := checkWritable(filepath.Dir(execPath)); err != nil {
38
- return fmt.Errorf("cannot update %s: %w", execPath, err)
36
+ slug := runtime.GOOS + "-" + runtime.GOARCH
37
+ binURL, ok := installer.OfficialAssetURL(slug, false)
38
+ if !ok {
39
+ ch <- ""
40
+ return ch
41
}
42
41
- binURL, _ := installer.OfficialAssetURL(slug, false)
43
+ go func() {
44
+ ch <- checkForUpdate(binURL)
45
+ }()
46
43
- latestVersion, err := detectLatestVersion(binURL)
47
+ return ch
48
+}
49
+
50
+func checkForUpdate(binURL string) string {
51
+ cacheDir, err := updateCacheDir()
52
if err != nil {
45
- return fmt.Errorf("failed to detect latest version: %w", err)
53
+ return ""
54
}
55
+ cachePath := filepath.Join(cacheDir, "update_check.json")
56
48
- if latestVersion == types.ReleaseVersion {
49
- fmt.Fprintf(os.Stderr, "Already up to date (%s).\n", types.ReleaseVersion)
50
- return nil
57
+ // Try reading the cache first.
58
+ if cached, err := readUpdateCache(cachePath); err == nil {
59
+ if time.Since(cached.CheckedAt) < updateCheckTTL {
60
+ if cached.LatestVersion != types.ReleaseVersion {
61
+ return cached.LatestVersion
62
+ }
63
+ return ""
64
+ }
65
}
66
53
- fmt.Fprintf(os.Stderr, "Updating %s → %s ...\n", types.ReleaseVersion, latestVersion)
54
-
55
- tmpFile, err := os.CreateTemp("", "portal-update-*")
67
+ // Cache is missing or stale — check the network.
68
+ latestVersion, err := detectLatestVersion(binURL)
69
if err != nil {
57
- return fmt.Errorf("failed to create temp file: %w", err)
70
+ return ""
71
}
59
- defer func() { _ = os.Remove(tmpFile.Name()) }()
72
61
- if err := downloadBinary(binURL, tmpFile); err != nil {
62
- _ = tmpFile.Close()
63
- return fmt.Errorf("failed to download binary: %w", err)
64
- }
65
- if err := tmpFile.Sync(); err != nil {
66
- _ = tmpFile.Close()
67
- return fmt.Errorf("failed to sync downloaded binary: %w", err)
68
- }
69
- _ = tmpFile.Close()
73
+ // Write cache atomically.
74
+ writeUpdateCache(cachePath, updateCache{
75
+ CheckedAt: time.Now(),
76
+ LatestVersion: latestVersion,
77
+ })
78
71
- checksumURL, _ := installer.OfficialAssetURL(slug, true)
72
- if err := verifyChecksum(tmpFile.Name(), checksumURL); err != nil {
73
- return fmt.Errorf("checksum verification failed: %w", err)
79
+ if latestVersion != types.ReleaseVersion {
80
+ return latestVersion
81
}
82
+ return ""
83
+}
84
76
- if err := replaceBinary(tmpFile.Name(), execPath); err != nil {
77
- return fmt.Errorf("failed to replace binary: %w", err)
85
+// printUpdateHint collects from the channel with a short timeout and prints
86
+// a hint to stderr if a newer version is available.
87
+func printUpdateHint(ch <-chan string) {
88
+ if ch == nil {
89
+ return
90
+ }
91
+ select {
92
+ case version := <-ch:
93
+ if version != "" {
94
+ fmt.Fprintf(os.Stderr, "\nA new version is available: %s. Run 'portal update' to upgrade.\n", version)
95
+ }
96
+ case <-time.After(500 * time.Millisecond):
97
}
79
-
80
- fmt.Fprintf(os.Stderr, "Updated %s → %s\n", types.ReleaseVersion, latestVersion)
81
- return nil
98
}
99
100
// detectLatestVersion sends a HEAD request to the GitHub releases latest URL
@@ -172,7 +188,11 @@ func verifyChecksum(filePath, checksumURL string) error {
188
return fmt.Errorf("failed to read checksum response: %w", err)
189
}
190
175
- expectedHash := strings.ToLower(strings.Fields(strings.TrimSpace(string(body)))[0])
191
+ fields := strings.Fields(strings.TrimSpace(string(body)))
192
+ if len(fields) == 0 {
193
+ return fmt.Errorf("empty checksum response")
194
+ }
195
+ expectedHash := strings.ToLower(fields[0])
196
if len(expectedHash) != 64 {
197
return fmt.Errorf("invalid checksum format (expected 64 hex chars, got %d)", len(expectedHash))
198
}
@@ -264,3 +284,50 @@ func replaceBinaryWindows(srcPath, dstPath string) error {
284
_ = os.Remove(oldPath)
285
return nil
286
}
287
+
288
+func updateCacheDir() (string, error) {
289
+ base, err := os.UserCacheDir()
290
+ if err != nil {
291
+ return "", err
292
+ }
293
+ dir := filepath.Join(base, "portal-tunnel")
294
+ if err := os.MkdirAll(dir, 0755); err != nil {
295
+ return "", err
296
+ }
297
+ return dir, nil
298
+}
299
+
300
+func readUpdateCache(path string) (updateCache, error) {
301
+ data, err := os.ReadFile(path)
302
+ if err != nil {
303
+ return updateCache{}, err
304
+ }
305
+ var c updateCache
306
+ if err := json.Unmarshal(data, &c); err != nil {
307
+ return updateCache{}, err
308
+ }
309
+ return c, nil
310
+}
311
+
312
+func writeUpdateCache(path string, c updateCache) {
313
+ data, err := json.Marshal(c)
314
+ if err != nil {
315
+ return
316
+ }
317
+
318
+ dir := filepath.Dir(path)
319
+ tmp, err := os.CreateTemp(dir, ".update_check-*.json")
320
+ if err != nil {
321
+ return
322
+ }
323
+ tmpName := tmp.Name()
324
+
325
+ if _, err := tmp.Write(data); err != nil {
326
+ _ = tmp.Close()
327
+ _ = os.Remove(tmpName)
328
+ return
329
+ }
330
+ _ = tmp.Close()
331
+
332
+ _ = os.Rename(tmpName, path)
333
+}
cmd/portal-tunnel/update_check.go
deleted
-139
@@ -1,139 +0,0 @@
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
-}