Skip to content

Commit bc69a09

Browse files
edumanskyclaude
andcommitted
Add self-update command
Downloads the latest release from GitHub and atomically replaces the running binary. Supports tar.gz (macOS/Linux) and zip (Windows). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 028ff57 commit bc69a09

2 files changed

Lines changed: 241 additions & 1 deletion

File tree

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ func skipAuth(cmd *cobra.Command) bool {
126126
name := cmd.Name()
127127
for c := cmd; c != nil; c = c.Parent() {
128128
switch c.Name() {
129-
case "login", "logout", "auth", "version", "help", "five9":
129+
case "login", "logout", "auth", "version", "update", "help", "five9":
130130
if c.Name() == "five9" {
131131
continue
132132
}

cmd/update.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
package cmd
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"path/filepath"
14+
"runtime"
15+
"strconv"
16+
"strings"
17+
18+
"github.com/spf13/cobra"
19+
)
20+
21+
var updateCmd = &cobra.Command{
22+
Use: "update",
23+
Short: "Update five9-cli to the latest version",
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
return runUpdate()
26+
},
27+
}
28+
29+
func init() {
30+
rootCmd.AddCommand(updateCmd)
31+
}
32+
33+
func runUpdate() error {
34+
latest, err := fetchLatestVersion()
35+
if err != nil {
36+
return fmt.Errorf("checking latest version: %w", err)
37+
}
38+
39+
current := Version
40+
if !isNewer(latest, current) {
41+
fmt.Printf("Already up to date (v%s)\n", current)
42+
return nil
43+
}
44+
45+
fmt.Printf("Updating v%s -> v%s\n", current, latest)
46+
47+
binPath, err := executablePath()
48+
if err != nil {
49+
return fmt.Errorf("locating binary: %w", err)
50+
}
51+
52+
url := archiveURL(latest)
53+
if err := downloadAndReplace(url, binPath); err != nil {
54+
return err
55+
}
56+
57+
fmt.Printf("Updated to v%s\n", latest)
58+
return nil
59+
}
60+
61+
// fetchLatestVersion queries the GitHub releases API and returns the latest
62+
// version string (without "v" prefix).
63+
func fetchLatestVersion() (string, error) {
64+
resp, err := http.Get("https://api.github.com/repos/Cloverhound/five9-cli/releases/latest")
65+
if err != nil {
66+
return "", err
67+
}
68+
defer resp.Body.Close()
69+
70+
if resp.StatusCode != http.StatusOK {
71+
return "", fmt.Errorf("GitHub API returned %s", resp.Status)
72+
}
73+
74+
var release struct {
75+
TagName string `json:"tag_name"`
76+
}
77+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
78+
return "", err
79+
}
80+
81+
return strings.TrimPrefix(release.TagName, "v"), nil
82+
}
83+
84+
// isNewer returns true if latest is a higher semver than current.
85+
func isNewer(latest, current string) bool {
86+
parse := func(v string) [3]int {
87+
var parts [3]int
88+
for i, s := range strings.SplitN(v, ".", 3) {
89+
parts[i], _ = strconv.Atoi(s)
90+
}
91+
return parts
92+
}
93+
l, c := parse(latest), parse(current)
94+
for i := range 3 {
95+
if l[i] > c[i] {
96+
return true
97+
}
98+
if l[i] < c[i] {
99+
return false
100+
}
101+
}
102+
return false
103+
}
104+
105+
// executablePath returns the resolved path to the running binary.
106+
func executablePath() (string, error) {
107+
exe, err := os.Executable()
108+
if err != nil {
109+
return "", err
110+
}
111+
return filepath.EvalSymlinks(exe)
112+
}
113+
114+
// archiveURL builds the download URL for a given version, matching the
115+
// GoReleaser naming template: five9-cli_{VERSION}_{OS}_{ARCH}.tar.gz (.zip on Windows).
116+
func archiveURL(version string) string {
117+
ext := "tar.gz"
118+
if runtime.GOOS == "windows" {
119+
ext = "zip"
120+
}
121+
return fmt.Sprintf(
122+
"https://github.com/Cloverhound/five9-cli/releases/download/v%s/five9-cli_%s_%s_%s.%s",
123+
version, version, runtime.GOOS, runtime.GOARCH, ext,
124+
)
125+
}
126+
127+
// downloadAndReplace downloads the archive from url, extracts the binary, and
128+
// atomically replaces the binary at binaryPath.
129+
func downloadAndReplace(url, binaryPath string) error {
130+
resp, err := http.Get(url)
131+
if err != nil {
132+
return fmt.Errorf("downloading release: %w", err)
133+
}
134+
defer resp.Body.Close()
135+
136+
if resp.StatusCode != http.StatusOK {
137+
return fmt.Errorf("download failed: %s", resp.Status)
138+
}
139+
140+
body, err := io.ReadAll(resp.Body)
141+
if err != nil {
142+
return fmt.Errorf("reading download: %w", err)
143+
}
144+
145+
var bin []byte
146+
if runtime.GOOS == "windows" {
147+
bin, err = extractFromZip(body)
148+
} else {
149+
bin, err = extractFromTarGz(body)
150+
}
151+
if err != nil {
152+
return fmt.Errorf("extracting binary: %w", err)
153+
}
154+
155+
// Write to a temp file in the same directory so os.Rename is atomic.
156+
dir := filepath.Dir(binaryPath)
157+
tmp, err := os.CreateTemp(dir, "five9-update-*")
158+
if err != nil {
159+
if os.IsPermission(err) {
160+
return fmt.Errorf("permission denied writing to %s — try: sudo five9 update", dir)
161+
}
162+
return fmt.Errorf("creating temp file: %w", err)
163+
}
164+
tmpPath := tmp.Name()
165+
166+
if _, err := tmp.Write(bin); err != nil {
167+
tmp.Close()
168+
os.Remove(tmpPath)
169+
return fmt.Errorf("writing temp file: %w", err)
170+
}
171+
if err := tmp.Chmod(0755); err != nil {
172+
tmp.Close()
173+
os.Remove(tmpPath)
174+
return fmt.Errorf("setting permissions: %w", err)
175+
}
176+
tmp.Close()
177+
178+
// On Windows, rename the old binary out of the way first.
179+
if runtime.GOOS == "windows" {
180+
oldPath := binaryPath + ".old"
181+
os.Remove(oldPath)
182+
if err := os.Rename(binaryPath, oldPath); err != nil {
183+
os.Remove(tmpPath)
184+
return fmt.Errorf("renaming old binary: %w", err)
185+
}
186+
}
187+
188+
if err := os.Rename(tmpPath, binaryPath); err != nil {
189+
os.Remove(tmpPath)
190+
if os.IsPermission(err) {
191+
return fmt.Errorf("permission denied replacing %s — try: sudo five9 update", binaryPath)
192+
}
193+
return fmt.Errorf("replacing binary: %w", err)
194+
}
195+
196+
return nil
197+
}
198+
199+
// extractFromTarGz extracts the "five9" binary from a .tar.gz archive.
200+
func extractFromTarGz(data []byte) ([]byte, error) {
201+
gz, err := gzip.NewReader(bytes.NewReader(data))
202+
if err != nil {
203+
return nil, err
204+
}
205+
defer gz.Close()
206+
207+
tr := tar.NewReader(gz)
208+
for {
209+
hdr, err := tr.Next()
210+
if err == io.EOF {
211+
break
212+
}
213+
if err != nil {
214+
return nil, err
215+
}
216+
if filepath.Base(hdr.Name) == "five9" {
217+
return io.ReadAll(tr)
218+
}
219+
}
220+
return nil, fmt.Errorf("binary not found in archive")
221+
}
222+
223+
// extractFromZip extracts the "five9.exe" binary from a .zip archive.
224+
func extractFromZip(data []byte) ([]byte, error) {
225+
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
226+
if err != nil {
227+
return nil, err
228+
}
229+
for _, f := range zr.File {
230+
if filepath.Base(f.Name) == "five9.exe" {
231+
rc, err := f.Open()
232+
if err != nil {
233+
return nil, err
234+
}
235+
defer rc.Close()
236+
return io.ReadAll(rc)
237+
}
238+
}
239+
return nil, fmt.Errorf("binary not found in archive")
240+
}

0 commit comments

Comments
 (0)