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