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 -}