Skip to content

Commit 83fdcfd

Browse files
cailmdaleyclaude
andcommitted
feat: add self-update command
`felt update` checks GitHub releases for the latest version, downloads the appropriate binary for the current OS/arch, and replaces the running binary atomically. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent df96c96 commit 83fdcfd

File tree

2 files changed

+167
-2
lines changed

2 files changed

+167
-2
lines changed

cmd/root.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ import (
1010

1111
var jsonOutput bool
1212

13+
// Version is the current version, set via ldflags.
14+
var Version = "dev"
15+
1316
// SetVersionInfo sets version info from main (populated via ldflags)
14-
func SetVersionInfo(version, commit, date string) {
15-
rootCmd.Version = version
17+
func SetVersionInfo(v, commit, date string) {
18+
Version = v
19+
rootCmd.Version = v
1620
}
1721

1822
var rootCmd = &cobra.Command{

cmd/update.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package cmd
2+
3+
import (
4+
"archive/tar"
5+
"compress/gzip"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"os"
11+
"runtime"
12+
"strings"
13+
14+
"github.com/spf13/cobra"
15+
)
16+
17+
type ghRelease struct {
18+
TagName string `json:"tag_name"`
19+
}
20+
21+
func init() {
22+
rootCmd.AddCommand(updateCmd)
23+
}
24+
25+
var updateCmd = &cobra.Command{
26+
Use: "update",
27+
Short: "Update felt to the latest version",
28+
RunE: func(cmd *cobra.Command, args []string) error {
29+
// Get latest release tag from GitHub
30+
latest, err := latestVersion()
31+
if err != nil {
32+
return fmt.Errorf("checking latest version: %w", err)
33+
}
34+
35+
current := Version
36+
latestClean := strings.TrimPrefix(latest, "v")
37+
38+
if current == latestClean {
39+
fmt.Printf("Already up to date (%s)\n", current)
40+
return nil
41+
}
42+
43+
if current == "dev" {
44+
fmt.Println("Running a dev build — cannot determine current version.")
45+
fmt.Printf("Latest release is %s. Continue? [y/N] ", latest)
46+
var answer string
47+
fmt.Scanln(&answer)
48+
if answer != "y" && answer != "Y" {
49+
return nil
50+
}
51+
} else {
52+
fmt.Printf("Updating %s → %s\n", current, latestClean)
53+
}
54+
55+
// Build asset name matching goreleaser template
56+
assetName := fmt.Sprintf("felt_%s_%s.tar.gz", archiveOS(), archiveArch())
57+
url := fmt.Sprintf("https://github.com/cailmdaley/felt/releases/download/%s/%s", latest, assetName)
58+
59+
// Download
60+
resp, err := http.Get(url)
61+
if err != nil {
62+
return fmt.Errorf("downloading release: %w", err)
63+
}
64+
defer resp.Body.Close()
65+
if resp.StatusCode != 200 {
66+
return fmt.Errorf("download failed: %s (asset: %s)", resp.Status, assetName)
67+
}
68+
69+
// Extract the "felt" binary from the tar.gz
70+
binary, err := extractBinary(resp.Body)
71+
if err != nil {
72+
return fmt.Errorf("extracting binary: %w", err)
73+
}
74+
75+
// Replace the running binary
76+
exe, err := os.Executable()
77+
if err != nil {
78+
return fmt.Errorf("locating current binary: %w", err)
79+
}
80+
81+
// Atomic-ish replace: rename old, write new, remove old
82+
old := exe + ".old"
83+
if err := os.Rename(exe, old); err != nil {
84+
return fmt.Errorf("backing up current binary: %w (try running with sudo?)", err)
85+
}
86+
87+
if err := os.WriteFile(exe, binary, 0755); err != nil {
88+
// Try to restore
89+
os.Rename(old, exe)
90+
return fmt.Errorf("writing new binary: %w", err)
91+
}
92+
93+
os.Remove(old)
94+
fmt.Printf("Updated to %s\n", latestClean)
95+
return nil
96+
},
97+
}
98+
99+
func latestVersion() (string, error) {
100+
resp, err := http.Get("https://api.github.com/repos/cailmdaley/felt/releases/latest")
101+
if err != nil {
102+
return "", err
103+
}
104+
defer resp.Body.Close()
105+
if resp.StatusCode != 200 {
106+
return "", fmt.Errorf("GitHub API: %s", resp.Status)
107+
}
108+
var rel ghRelease
109+
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
110+
return "", err
111+
}
112+
return rel.TagName, nil
113+
}
114+
115+
// archiveOS returns the OS name as goreleaser formats it (title case).
116+
func archiveOS() string {
117+
switch runtime.GOOS {
118+
case "darwin":
119+
return "Darwin"
120+
case "linux":
121+
return "Linux"
122+
default:
123+
if len(runtime.GOOS) == 0 {
124+
return ""
125+
}
126+
return strings.ToUpper(runtime.GOOS[:1]) + runtime.GOOS[1:]
127+
}
128+
}
129+
130+
// archiveArch returns the arch as goreleaser formats it.
131+
func archiveArch() string {
132+
switch runtime.GOARCH {
133+
case "amd64":
134+
return "x86_64"
135+
default:
136+
return runtime.GOARCH
137+
}
138+
}
139+
140+
func extractBinary(r io.Reader) ([]byte, error) {
141+
gz, err := gzip.NewReader(r)
142+
if err != nil {
143+
return nil, err
144+
}
145+
defer gz.Close()
146+
147+
tr := tar.NewReader(gz)
148+
for {
149+
hdr, err := tr.Next()
150+
if err == io.EOF {
151+
break
152+
}
153+
if err != nil {
154+
return nil, err
155+
}
156+
if hdr.Name == "felt" || strings.HasSuffix(hdr.Name, "/felt") {
157+
return io.ReadAll(tr)
158+
}
159+
}
160+
return nil, fmt.Errorf("binary 'felt' not found in archive")
161+
}

0 commit comments

Comments
 (0)