Skip to content

Commit 2e47a3b

Browse files
committed
feat: auto-updates setup
1 parent 61e1d8c commit 2e47a3b

5 files changed

Lines changed: 222 additions & 1 deletion

File tree

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ builds:
1010
- amd64
1111
- arm64
1212
ldflags:
13-
- -s -w
13+
- -s -w -X github.com/nmashchenko/aegis-cli/cmd.Version={{ .Tag }}
1414

1515
archives:
1616
- format: tar.gz

cmd/root.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import (
1212
"github.com/spf13/cobra"
1313
)
1414

15+
// Version is set at build time via ldflags.
16+
var Version = "dev"
17+
1518
var (
1619
database *db.DB
1720
sessionSvc *session.Service

cmd/update.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/nmashchenko/aegis-cli/internal/updater"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var updateCmd = &cobra.Command{
13+
Use: "update",
14+
Short: "Update aegis to the latest version",
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
17+
greenStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#22C55E"))
18+
19+
fmt.Fprintf(os.Stdout, "%s\n", labelStyle.Render("Checking for updates..."))
20+
21+
latest, err := updater.GetLatestVersion()
22+
if err != nil {
23+
return fmt.Errorf("failed to check for updates: %w", err)
24+
}
25+
26+
if !updater.IsNewer(Version, latest) {
27+
fmt.Fprintf(os.Stdout, "%s %s\n",
28+
greenStyle.Render("Already up to date:"),
29+
greenStyle.Render(Version),
30+
)
31+
return nil
32+
}
33+
34+
fmt.Fprintf(os.Stdout, "%s %s → %s\n",
35+
labelStyle.Render("Updating:"),
36+
labelStyle.Render(Version),
37+
greenStyle.Render(latest),
38+
)
39+
40+
if err := updater.Update(latest); err != nil {
41+
return fmt.Errorf("update failed: %w", err)
42+
}
43+
44+
fmt.Fprintf(os.Stdout, "%s\n", greenStyle.Render("Updated successfully!"))
45+
return nil
46+
},
47+
}
48+
49+
func init() {
50+
rootCmd.AddCommand(updateCmd)
51+
}

cmd/version.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/charmbracelet/lipgloss"
8+
"github.com/nmashchenko/aegis-cli/internal/updater"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
var versionCmd = &cobra.Command{
13+
Use: "version",
14+
Short: "Show current version",
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
17+
valueStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15"))
18+
greenStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#22C55E"))
19+
20+
fmt.Fprintf(os.Stdout, "%s %s\n",
21+
labelStyle.Render("aegis"),
22+
valueStyle.Render(Version),
23+
)
24+
25+
latest, err := updater.GetLatestVersion()
26+
if err != nil {
27+
return nil // silently skip if offline
28+
}
29+
30+
if updater.IsNewer(Version, latest) {
31+
fmt.Fprintf(os.Stdout, "%s %s\n",
32+
labelStyle.Render("Update available:"),
33+
greenStyle.Render(latest),
34+
)
35+
fmt.Fprintf(os.Stdout, "%s\n",
36+
labelStyle.Render("Run \"aegis update\" to install"),
37+
)
38+
} else {
39+
fmt.Fprintf(os.Stdout, "%s\n",
40+
greenStyle.Render("Up to date"),
41+
)
42+
}
43+
44+
return nil
45+
},
46+
}
47+
48+
func init() {
49+
rootCmd.AddCommand(versionCmd)
50+
}

internal/updater/updater.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package updater
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+
15+
const repo = "nmashchenko/aegis-cli"
16+
17+
type release struct {
18+
TagName string `json:"tag_name"`
19+
}
20+
21+
// GetLatestVersion fetches the latest release tag from GitHub.
22+
func GetLatestVersion() (string, error) {
23+
resp, err := http.Get(fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo))
24+
if err != nil {
25+
return "", fmt.Errorf("check for updates: %w", err)
26+
}
27+
defer resp.Body.Close()
28+
29+
if resp.StatusCode != 200 {
30+
return "", fmt.Errorf("github API returned %d", resp.StatusCode)
31+
}
32+
33+
var r release
34+
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
35+
return "", fmt.Errorf("parse release: %w", err)
36+
}
37+
return r.TagName, nil
38+
}
39+
40+
// IsNewer returns true if latest is a higher version than current.
41+
func IsNewer(current, latest string) bool {
42+
current = strings.TrimPrefix(current, "v")
43+
latest = strings.TrimPrefix(latest, "v")
44+
if current == "dev" || current == "" {
45+
return false
46+
}
47+
return latest != current
48+
}
49+
50+
// Update downloads the latest release and replaces the current binary.
51+
func Update(latest string) error {
52+
goos := runtime.GOOS
53+
goarch := runtime.GOARCH
54+
55+
url := fmt.Sprintf(
56+
"https://github.com/%s/releases/download/%s/aegis_%s_%s.tar.gz",
57+
repo, latest, goos, goarch,
58+
)
59+
60+
resp, err := http.Get(url)
61+
if err != nil {
62+
return fmt.Errorf("download release: %w", err)
63+
}
64+
defer resp.Body.Close()
65+
66+
if resp.StatusCode != 200 {
67+
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
68+
}
69+
70+
// Extract the aegis binary from the tar.gz
71+
gz, err := gzip.NewReader(resp.Body)
72+
if err != nil {
73+
return fmt.Errorf("decompress: %w", err)
74+
}
75+
defer gz.Close()
76+
77+
tr := tar.NewReader(gz)
78+
for {
79+
hdr, err := tr.Next()
80+
if err == io.EOF {
81+
return fmt.Errorf("binary not found in archive")
82+
}
83+
if err != nil {
84+
return fmt.Errorf("read archive: %w", err)
85+
}
86+
if hdr.Name == "aegis" {
87+
break
88+
}
89+
}
90+
91+
// Get path of current executable
92+
execPath, err := os.Executable()
93+
if err != nil {
94+
return fmt.Errorf("find executable path: %w", err)
95+
}
96+
97+
// Write to a temp file next to the binary, then rename (atomic on same fs)
98+
tmpPath := execPath + ".tmp"
99+
f, err := os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
100+
if err != nil {
101+
return fmt.Errorf("create temp file: %w", err)
102+
}
103+
104+
if _, err := io.Copy(f, tr); err != nil {
105+
f.Close()
106+
os.Remove(tmpPath)
107+
return fmt.Errorf("write binary: %w", err)
108+
}
109+
f.Close()
110+
111+
if err := os.Rename(tmpPath, execPath); err != nil {
112+
os.Remove(tmpPath)
113+
return fmt.Errorf("replace binary: %w", err)
114+
}
115+
116+
return nil
117+
}

0 commit comments

Comments
 (0)