Skip to content

Commit 1c99f01

Browse files
committed
fix(build): include upgrade.go source and unignore internal tests
1 parent 6a950fd commit 1c99f01

10 files changed

Lines changed: 481 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Binaries
2-
thermal
2+
/thermal
33
/dist/
44
*.exe
55

cmd/thermal/upgrade.go

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
package main
2+
3+
import (
4+
"archive/tar"
5+
"compress/gzip"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"runtime"
14+
"strings"
15+
"time"
16+
17+
"github.com/jadmadi/thermal/internal/version"
18+
)
19+
20+
const (
21+
githubAPI = "https://api.github.com/repos/jadmadi/thermal/releases/latest"
22+
githubReleases = "https://github.com/jadmadi/thermal/releases"
23+
)
24+
25+
type githubRelease struct {
26+
TagName string `json:"tag_name"`
27+
Name string `json:"name"`
28+
HTMLURL string `json:"html_url"`
29+
Assets []asset `json:"assets"`
30+
}
31+
32+
type asset struct {
33+
Name string `json:"name"`
34+
BrowserDownloadURL string `json:"browser_download_url"`
35+
Size int64 `json:"size"`
36+
}
37+
38+
var httpClient = &http.Client{Timeout: 30 * time.Second}
39+
40+
// runUpgrade checks for a newer release and self-replaces the binary.
41+
func runUpgrade() int {
42+
fmt.Printf(" thermal %s checking for updates...\n", version.String())
43+
44+
current := strings.TrimPrefix(version.Version, "v")
45+
46+
// Fetch latest release from GitHub API.
47+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
48+
defer cancel()
49+
50+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubAPI, nil)
51+
if err != nil {
52+
fmt.Fprintf(os.Stderr, " error: cannot create request: %v\n", err)
53+
return 1
54+
}
55+
56+
resp, err := httpClient.Do(req)
57+
if err != nil {
58+
fmt.Fprintf(os.Stderr, " error: cannot reach GitHub API: %v\n", err)
59+
return 1
60+
}
61+
defer resp.Body.Close()
62+
63+
if resp.StatusCode != 200 {
64+
fmt.Fprintf(os.Stderr, " error: GitHub API returned %s\n", resp.Status)
65+
return 1
66+
}
67+
68+
var release githubRelease
69+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
70+
fmt.Fprintf(os.Stderr, " error: cannot parse release info: %v\n", err)
71+
return 1
72+
}
73+
74+
latest := strings.TrimPrefix(release.TagName, "v")
75+
76+
if current == "dev" {
77+
fmt.Printf(" current: dev (built from source)\n")
78+
fmt.Printf(" latest: %s\n", release.TagName)
79+
} else if current == latest {
80+
fmt.Printf(" already up to date — %s\n", release.TagName)
81+
return 0
82+
} else {
83+
fmt.Printf(" update available: %s → %s\n", "v"+current, release.TagName)
84+
}
85+
86+
// Find the matching asset for this OS/arch.
87+
assetName, downloadURL, err := findAsset(release.Assets)
88+
if err != nil {
89+
fmt.Fprintf(os.Stderr, " error: %v\n", err)
90+
fmt.Fprintf(os.Stderr, " download manually: %s\n", release.HTMLURL)
91+
return 1
92+
}
93+
94+
fmt.Printf(" downloading %s...\n", assetName)
95+
96+
// Download the archive.
97+
tmpDir, err := os.MkdirTemp("", "thermal-upgrade-*")
98+
if err != nil {
99+
fmt.Fprintf(os.Stderr, " error: cannot create temp dir: %v\n", err)
100+
return 1
101+
}
102+
defer os.RemoveAll(tmpDir)
103+
104+
archivePath := filepath.Join(tmpDir, assetName)
105+
if err := downloadFile(downloadURL, archivePath); err != nil {
106+
fmt.Fprintf(os.Stderr, " error: download failed: %v\n", err)
107+
return 1
108+
}
109+
110+
// Extract the binary from the archive.
111+
binaryPath := filepath.Join(tmpDir, "thermal")
112+
if err := extractBinary(archivePath, assetName, binaryPath); err != nil {
113+
fmt.Fprintf(os.Stderr, " error: extraction failed: %v\n", err)
114+
return 1
115+
}
116+
117+
// Make it executable (in case extraction lost the mode).
118+
if err := os.Chmod(binaryPath, 0755); err != nil {
119+
fmt.Fprintf(os.Stderr, " error: cannot chmod: %v\n", err)
120+
return 1
121+
}
122+
123+
// Find the current binary path.
124+
currentBin, err := os.Executable()
125+
if err != nil {
126+
fmt.Fprintf(os.Stderr, " error: cannot find current binary: %v\n", err)
127+
return 1
128+
}
129+
130+
// Resolve symlinks.
131+
currentBin, err = filepath.EvalSymlinks(currentBin)
132+
if err != nil {
133+
currentBin, _ = os.Executable()
134+
}
135+
136+
// Atomic swap: write to a temp file next to the target, then rename.
137+
oldPath := currentBin + ".old"
138+
tmpPath := currentBin + ".new"
139+
140+
// Copy the new binary to tmpPath.
141+
if err := copyFile(binaryPath, tmpPath); err != nil {
142+
fmt.Fprintf(os.Stderr, " error: cannot write new binary: %v\n", err)
143+
return 1
144+
}
145+
if err := os.Chmod(tmpPath, 0755); err != nil {
146+
fmt.Fprintf(os.Stderr, " error: cannot chmod: %v\n", err)
147+
return 1
148+
}
149+
150+
// Rename current → old, new → current.
151+
_ = os.Remove(oldPath)
152+
os.Rename(currentBin, oldPath)
153+
if err := os.Rename(tmpPath, currentBin); err != nil {
154+
// Try to restore.
155+
os.Rename(oldPath, currentBin)
156+
fmt.Fprintf(os.Stderr, " error: cannot replace binary: %v\n", err)
157+
return 1
158+
}
159+
160+
// Clean up the old binary (best-effort — may fail on Windows if locked).
161+
_ = os.Remove(oldPath)
162+
163+
fmt.Printf(" ✓ upgraded to %s\n", release.TagName)
164+
fmt.Printf(" restart thermal to use the new version.\n")
165+
return 0
166+
}
167+
168+
// findAsset finds the release asset matching the current OS and architecture.
169+
// Returns the asset name and its download URL.
170+
func findAsset(assets []asset) (string, string, error) {
171+
goos := runtime.GOOS
172+
goarch := runtime.GOARCH
173+
174+
// Map Go arch names to goreleaser arch names (they match, but be safe).
175+
archMap := map[string]string{
176+
"amd64": "amd64",
177+
"arm64": "arm64",
178+
"386": "386",
179+
}
180+
archName, ok := archMap[goarch]
181+
if !ok {
182+
archName = goarch
183+
}
184+
185+
// goreleaser naming: thermal_VERSION_OS_ARCH.tar.gz (or .zip for windows)
186+
for _, a := range assets {
187+
name := strings.ToLower(a.Name)
188+
osMatch := strings.Contains(name, "_"+goos+"_") || strings.Contains(name, "-"+goos+"-")
189+
archMatch := strings.Contains(name, "_"+archName+".") || strings.Contains(name, "_"+archName+"_")
190+
191+
if osMatch && archMatch {
192+
return a.Name, a.BrowserDownloadURL, nil
193+
}
194+
}
195+
196+
return "", "", fmt.Errorf("no binary found for %s/%s", goos, goarch)
197+
}
198+
199+
// downloadFile downloads a URL to a local path.
200+
func downloadFile(url, dest string) error {
201+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
202+
defer cancel()
203+
204+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
205+
if err != nil {
206+
return err
207+
}
208+
209+
resp, err := httpClient.Do(req)
210+
if err != nil {
211+
return err
212+
}
213+
defer resp.Body.Close()
214+
215+
if resp.StatusCode != 200 {
216+
return fmt.Errorf("HTTP %s", resp.Status)
217+
}
218+
219+
out, err := os.Create(dest)
220+
if err != nil {
221+
return err
222+
}
223+
defer out.Close()
224+
225+
_, err = io.Copy(out, resp.Body)
226+
return err
227+
}
228+
229+
// extractBinary extracts the thermal binary from a .tar.gz or .zip archive.
230+
func extractBinary(archivePath, archiveName, destPath string) error {
231+
if strings.HasSuffix(archiveName, ".zip") {
232+
return fmt.Errorf("zip extraction not yet supported — please extract manually")
233+
}
234+
235+
// .tar.gz
236+
f, err := os.Open(archivePath)
237+
if err != nil {
238+
return err
239+
}
240+
defer f.Close()
241+
242+
gz, err := gzip.NewReader(f)
243+
if err != nil {
244+
return err
245+
}
246+
defer gz.Close()
247+
248+
const maxBinarySize = 250 * 1024 * 1024 // 250 MB
249+
tr := tar.NewReader(gz)
250+
for {
251+
hdr, err := tr.Next()
252+
if err == io.EOF {
253+
break
254+
}
255+
if err != nil {
256+
return err
257+
}
258+
259+
// Look for the binary named "thermal" (not in a subdirectory).
260+
if filepath.Base(hdr.Name) == "thermal" && !strings.Contains(hdr.Name, "/") {
261+
out, err := os.Create(destPath)
262+
if err != nil {
263+
return err
264+
}
265+
defer out.Close()
266+
_, err = io.Copy(out, io.LimitReader(tr, maxBinarySize))
267+
return err
268+
}
269+
270+
// Also handle if it's in a subdirectory.
271+
if filepath.Base(hdr.Name) == "thermal" {
272+
out, err := os.Create(destPath)
273+
if err != nil {
274+
return err
275+
}
276+
defer out.Close()
277+
_, err = io.Copy(out, io.LimitReader(tr, maxBinarySize))
278+
return err
279+
}
280+
}
281+
282+
return fmt.Errorf("binary 'thermal' not found in archive")
283+
}
284+
285+
// copyFile copies a file from src to dst.
286+
func copyFile(src, dst string) error {
287+
in, err := os.Open(src)
288+
if err != nil {
289+
return err
290+
}
291+
defer in.Close()
292+
293+
out, err := os.Create(dst)
294+
if err != nil {
295+
return err
296+
}
297+
defer out.Close()
298+
299+
_, err = io.Copy(out, in)
300+
return err
301+
}

cmd/thermal/upgrade_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package main
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
)
7+
8+
func TestFindAsset_ExactMatching(t *testing.T) {
9+
assets := []asset{
10+
{Name: "thermal_0.3.0_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/linux_amd64"},
11+
{Name: "thermal_0.3.0_darwin_arm64.tar.gz", BrowserDownloadURL: "https://example.com/darwin_arm64"},
12+
}
13+
14+
name, _, err := findAsset(assets)
15+
if runtime.GOOS == "linux" && runtime.GOARCH == "amd64" {
16+
if err != nil || name != "thermal_0.3.0_linux_amd64.tar.gz" {
17+
t.Errorf("failed matching linux_amd64: %v", err)
18+
}
19+
} else if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
20+
if err != nil || name != "thermal_0.3.0_darwin_arm64.tar.gz" {
21+
t.Errorf("failed matching darwin_arm64: %v", err)
22+
}
23+
} else {
24+
// If on windows or other arch, must return clean error without picking wrong architecture
25+
if err == nil {
26+
t.Errorf("expected error when no matching architecture exists, got %q", name)
27+
}
28+
}
29+
}

internal/thermal/format_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package thermal
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestCompactNumber(t *testing.T) {
10+
tests := []struct {
11+
input int64
12+
want string
13+
}{
14+
{0, "0"},
15+
{999, "999"},
16+
{1000, "1.0K"},
17+
{1540, "1.5K"},
18+
{999999, "1000.0K"},
19+
{1000000, "1.0M"},
20+
{44000000, "44.0M"},
21+
{1000000000, "1.0B"},
22+
{1850000000, "1.9B"},
23+
}
24+
for _, tc := range tests {
25+
if got := CompactNumber(tc.input); got != tc.want {
26+
t.Errorf("CompactNumber(%d) = %q, want %q", tc.input, got, tc.want)
27+
}
28+
}
29+
}
30+
31+
func TestPadding(t *testing.T) {
32+
if got := PadRight("abc", 5); got != "abc " {
33+
t.Errorf("PadRight = %q, want %q", got, "abc ")
34+
}
35+
if got := PadRight("abcde", 3); got != "abcde" {
36+
t.Errorf("PadRight truncation = %q, want %q", got, "abcde")
37+
}
38+
if got := PadLeft("abc", 5); got != " abc" {
39+
t.Errorf("PadLeft = %q, want %q", got, " abc")
40+
}
41+
}
42+
43+
func TestFormatPath(t *testing.T) {
44+
home, err := os.UserHomeDir()
45+
if err != nil {
46+
t.Skip("no home dir")
47+
}
48+
sub := filepath.Join(home, ".local", "share", "opencode")
49+
got := FormatPath(sub)
50+
if got != "~/.local/share/opencode" && got != "~\\.local\\share\\opencode" {
51+
t.Errorf("FormatPath(%q) = %q", sub, got)
52+
}
53+
}

0 commit comments

Comments
 (0)