Skip to content

Commit b5d03f0

Browse files
committed
feat: add update command for sshy to fetch and install the latest version
1 parent bb1e943 commit b5d03f0

1 file changed

Lines changed: 285 additions & 0 deletions

File tree

cmd/update.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package cmd
2+
3+
import (
4+
"archive/tar"
5+
"archive/zip"
6+
"bufio"
7+
"compress/gzip"
8+
"encoding/json"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"path/filepath"
14+
"runtime"
15+
"strings"
16+
17+
"github.com/spf13/cobra"
18+
)
19+
20+
const (
21+
githubRepo = "omisai-tech/sshy"
22+
githubAPIURL = "https://api.github.com/repos/" + githubRepo + "/releases/latest"
23+
)
24+
25+
type githubRelease struct {
26+
TagName string `json:"tag_name"`
27+
Name string `json:"name"`
28+
}
29+
30+
var updateCmd = &cobra.Command{
31+
Use: "update",
32+
Short: "Update sshy to the latest version",
33+
Long: `Check for updates and update sshy to the latest release from GitHub.`,
34+
RunE: runUpdate,
35+
}
36+
37+
func init() {
38+
rootCmd.AddCommand(updateCmd)
39+
}
40+
41+
func runUpdate(cmd *cobra.Command, args []string) error {
42+
currentVersion := version
43+
fmt.Printf("Current version: %s\n", currentVersion)
44+
45+
latestVersion, err := fetchLatestVersion()
46+
if err != nil {
47+
return fmt.Errorf("failed to check for updates: %w", err)
48+
}
49+
fmt.Printf("Latest version: %s\n", latestVersion)
50+
51+
if isLatestVersion(currentVersion, latestVersion) {
52+
fmt.Println("\nYou are already running the latest version.")
53+
return nil
54+
}
55+
56+
fmt.Printf("\nA new version is available: %s -> %s\n", currentVersion, latestVersion)
57+
fmt.Print("Are you sure you want to update? [y/N]: ")
58+
59+
reader := bufio.NewReader(os.Stdin)
60+
response, err := reader.ReadString('\n')
61+
if err != nil {
62+
return fmt.Errorf("failed to read response: %w", err)
63+
}
64+
65+
response = strings.TrimSpace(strings.ToLower(response))
66+
if response != "y" && response != "yes" {
67+
fmt.Println("Update cancelled.")
68+
return nil
69+
}
70+
71+
if err := performUpdate(latestVersion); err != nil {
72+
return fmt.Errorf("failed to update: %w", err)
73+
}
74+
75+
fmt.Printf("\nSuccessfully updated to %s!\n", latestVersion)
76+
return nil
77+
}
78+
79+
func fetchLatestVersion() (string, error) {
80+
resp, err := http.Get(githubAPIURL)
81+
if err != nil {
82+
return "", err
83+
}
84+
defer resp.Body.Close()
85+
86+
if resp.StatusCode != http.StatusOK {
87+
return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
88+
}
89+
90+
var release githubRelease
91+
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
92+
return "", err
93+
}
94+
95+
return release.TagName, nil
96+
}
97+
98+
func isLatestVersion(current, latest string) bool {
99+
current = strings.TrimPrefix(current, "v")
100+
latest = strings.TrimPrefix(latest, "v")
101+
return current == latest
102+
}
103+
104+
func performUpdate(version string) error {
105+
execPath, err := os.Executable()
106+
if err != nil {
107+
return fmt.Errorf("failed to get executable path: %w", err)
108+
}
109+
110+
goos := runtime.GOOS
111+
goarch := runtime.GOARCH
112+
113+
versionNum := strings.TrimPrefix(version, "v")
114+
var archiveExt string
115+
if goos == "windows" {
116+
archiveExt = "zip"
117+
} else {
118+
archiveExt = "tar.gz"
119+
}
120+
121+
downloadURL := fmt.Sprintf(
122+
"https://github.com/%s/releases/download/%s/sshy_%s_%s_%s.%s",
123+
githubRepo, version, versionNum, goos, goarch, archiveExt,
124+
)
125+
fmt.Printf("Downloading from: %s\n", downloadURL)
126+
127+
resp, err := http.Get(downloadURL)
128+
if err != nil {
129+
return fmt.Errorf("failed to download: %w", err)
130+
}
131+
defer resp.Body.Close()
132+
133+
if resp.StatusCode != http.StatusOK {
134+
return fmt.Errorf("download failed with status %d", resp.StatusCode)
135+
}
136+
137+
tmpDir, err := os.MkdirTemp("", "sshy-update-*")
138+
if err != nil {
139+
return fmt.Errorf("failed to create temp directory: %w", err)
140+
}
141+
defer os.RemoveAll(tmpDir)
142+
143+
archivePath := filepath.Join(tmpDir, "sshy."+archiveExt)
144+
archiveFile, err := os.Create(archivePath)
145+
if err != nil {
146+
return fmt.Errorf("failed to create archive file: %w", err)
147+
}
148+
149+
if _, err := io.Copy(archiveFile, resp.Body); err != nil {
150+
archiveFile.Close()
151+
return fmt.Errorf("failed to write archive: %w", err)
152+
}
153+
archiveFile.Close()
154+
155+
var binaryPath string
156+
if goos == "windows" {
157+
binaryPath, err = extractFromZip(archivePath, tmpDir)
158+
} else {
159+
binaryPath, err = extractFromTarGz(archivePath, tmpDir)
160+
}
161+
if err != nil {
162+
return fmt.Errorf("failed to extract binary: %w", err)
163+
}
164+
165+
if err := os.Chmod(binaryPath, 0755); err != nil {
166+
return fmt.Errorf("failed to set permissions: %w", err)
167+
}
168+
169+
if err := os.Rename(binaryPath, execPath); err != nil {
170+
oldPath := execPath + ".old"
171+
if renameErr := os.Rename(execPath, oldPath); renameErr != nil {
172+
return fmt.Errorf("failed to backup old binary: %w", renameErr)
173+
}
174+
if copyErr := copyFile(binaryPath, execPath); copyErr != nil {
175+
os.Rename(oldPath, execPath)
176+
return fmt.Errorf("failed to install update: %w", copyErr)
177+
}
178+
os.Remove(oldPath)
179+
}
180+
181+
return nil
182+
}
183+
184+
func extractFromTarGz(archivePath, destDir string) (string, error) {
185+
file, err := os.Open(archivePath)
186+
if err != nil {
187+
return "", err
188+
}
189+
defer file.Close()
190+
191+
gzr, err := gzip.NewReader(file)
192+
if err != nil {
193+
return "", err
194+
}
195+
defer gzr.Close()
196+
197+
tr := tar.NewReader(gzr)
198+
var binaryPath string
199+
200+
for {
201+
header, err := tr.Next()
202+
if err == io.EOF {
203+
break
204+
}
205+
if err != nil {
206+
return "", err
207+
}
208+
209+
if header.Typeflag == tar.TypeReg && (header.Name == "sshy" || filepath.Base(header.Name) == "sshy") {
210+
binaryPath = filepath.Join(destDir, "sshy")
211+
outFile, err := os.Create(binaryPath)
212+
if err != nil {
213+
return "", err
214+
}
215+
if _, err := io.Copy(outFile, tr); err != nil {
216+
outFile.Close()
217+
return "", err
218+
}
219+
outFile.Close()
220+
break
221+
}
222+
}
223+
224+
if binaryPath == "" {
225+
return "", fmt.Errorf("sshy binary not found in archive")
226+
}
227+
return binaryPath, nil
228+
}
229+
230+
func extractFromZip(archivePath, destDir string) (string, error) {
231+
r, err := zip.OpenReader(archivePath)
232+
if err != nil {
233+
return "", err
234+
}
235+
defer r.Close()
236+
237+
var binaryPath string
238+
for _, f := range r.File {
239+
if f.Name == "sshy.exe" || filepath.Base(f.Name) == "sshy.exe" {
240+
binaryPath = filepath.Join(destDir, "sshy.exe")
241+
rc, err := f.Open()
242+
if err != nil {
243+
return "", err
244+
}
245+
outFile, err := os.Create(binaryPath)
246+
if err != nil {
247+
rc.Close()
248+
return "", err
249+
}
250+
if _, err := io.Copy(outFile, rc); err != nil {
251+
outFile.Close()
252+
rc.Close()
253+
return "", err
254+
}
255+
outFile.Close()
256+
rc.Close()
257+
break
258+
}
259+
}
260+
261+
if binaryPath == "" {
262+
return "", fmt.Errorf("sshy.exe binary not found in archive")
263+
}
264+
return binaryPath, nil
265+
}
266+
267+
func copyFile(src, dst string) error {
268+
source, err := os.Open(src)
269+
if err != nil {
270+
return err
271+
}
272+
defer source.Close()
273+
274+
destination, err := os.Create(dst)
275+
if err != nil {
276+
return err
277+
}
278+
defer destination.Close()
279+
280+
if _, err := io.Copy(destination, source); err != nil {
281+
return err
282+
}
283+
284+
return os.Chmod(dst, 0755)
285+
}

0 commit comments

Comments
 (0)