1+ package cmd
2+
3+ import (
4+ "encoding/json"
5+ "fmt"
6+ "io"
7+ "net/http"
8+ "os"
9+ "os/exec"
10+ "strings"
11+ "time"
12+
13+ "github.com/fatih/color"
14+ "github.com/spf13/cobra"
15+ )
16+
17+ var (
18+ forceUpgrade bool
19+ checkOnly bool
20+ )
21+
22+ // upgradeCmd represents the upgrade command
23+ var upgradeCmd = & cobra.Command {
24+ Use : "upgrade" ,
25+ Short : "Check for updates and upgrade the AsyncStatus CLI" ,
26+ Long : `Check for the latest version of AsyncStatus CLI and upgrade if a newer version is available.
27+
28+ This command will:
29+ 1. Check the current version
30+ 2. Fetch the latest version from GitHub releases
31+ 3. Compare versions and prompt for upgrade if needed
32+ 4. Download and install the latest version
33+
34+ Examples:
35+ asyncstatus upgrade # Check and upgrade if newer version available
36+ asyncstatus upgrade --check # Only check for updates, don't install
37+ asyncstatus upgrade --force # Force upgrade even if already on latest version` ,
38+ Args : cobra .NoArgs ,
39+ Run : func (cmd * cobra.Command , args []string ) {
40+ if err := handleUpgrade (); err != nil {
41+ color .New (color .FgRed ).Printf ("⧗ upgrade failed: %v\n " , err )
42+ os .Exit (1 )
43+ }
44+ },
45+ }
46+
47+ func init () {
48+ rootCmd .AddCommand (upgradeCmd )
49+ upgradeCmd .Flags ().BoolVar (& forceUpgrade , "force" , false , "Force upgrade even if already on latest version" )
50+ upgradeCmd .Flags ().BoolVar (& checkOnly , "check" , false , "Only check for updates, don't install" )
51+ }
52+
53+ // GitHubRelease represents a GitHub release
54+ type GitHubRelease struct {
55+ TagName string `json:"tag_name"`
56+ Name string `json:"name"`
57+ Draft bool `json:"draft"`
58+ PreRelease bool `json:"prerelease"`
59+ Assets []struct {
60+ Name string `json:"name"`
61+ BrowserDownloadURL string `json:"browser_download_url"`
62+ } `json:"assets"`
63+ }
64+
65+ // handleUpgrade processes the upgrade flow
66+ func handleUpgrade () error {
67+ // Get current version
68+ currentVersion := Version
69+ if currentVersion == "dev" && ! forceUpgrade {
70+ color .New (color .FgYellow ).Println ("⧗ development build detected, use --force to upgrade" )
71+ return nil
72+ }
73+
74+ // Get latest version from GitHub
75+ latestVersion , err := getLatestVersion ()
76+ if err != nil {
77+ return fmt .Errorf ("failed to check latest version: %v" , err )
78+ }
79+
80+ // Compare versions
81+ if ! forceUpgrade && isVersionCurrent (currentVersion , latestVersion ) {
82+ color .New (color .FgGreen ).Println ("⧗ already on latest version" )
83+ return nil
84+ }
85+
86+ if checkOnly {
87+ if isVersionCurrent (currentVersion , latestVersion ) {
88+ color .New (color .FgGreen ).Println ("⧗ already on latest version" )
89+ } else {
90+ color .New (color .FgYellow ).Printf ("⧗ newer version available: %s\n " , latestVersion )
91+ }
92+ return nil
93+ }
94+
95+ // Prompt for upgrade
96+ if ! forceUpgrade {
97+ color .New (color .FgYellow ).Printf ("⧗ upgrade to %s? [y/N]: " , latestVersion )
98+
99+ var response string
100+ if _ , err := fmt .Scanln (& response ); err != nil {
101+ return fmt .Errorf ("failed to read input: %v" , err )
102+ }
103+
104+ response = strings .ToLower (strings .TrimSpace (response ))
105+ if response != "y" && response != "yes" {
106+ return nil
107+ }
108+ }
109+
110+ // Perform upgrade
111+ return performUpgrade (latestVersion )
112+ }
113+
114+ // getLatestVersion fetches the latest release version from GitHub
115+ func getLatestVersion () (string , error ) {
116+ const githubAPI = "https://api.github.com/repos/AsyncStatus/asyncstatus/releases/latest"
117+
118+ client := & http.Client {
119+ Timeout : 30 * time .Second ,
120+ }
121+
122+ req , err := http .NewRequest ("GET" , githubAPI , nil )
123+ if err != nil {
124+ return "" , fmt .Errorf ("failed to create request: %v" , err )
125+ }
126+
127+ req .Header .Set ("User-Agent" , "AsyncStatus-CLI/" + Version )
128+ req .Header .Set ("Accept" , "application/vnd.github.v3+json" )
129+
130+ resp , err := client .Do (req )
131+ if err != nil {
132+ return "" , fmt .Errorf ("failed to fetch release info: %v" , err )
133+ }
134+ defer resp .Body .Close ()
135+
136+ if resp .StatusCode != 200 {
137+ body , _ := io .ReadAll (resp .Body )
138+ return "" , fmt .Errorf ("GitHub API error (status %d): %s" , resp .StatusCode , string (body ))
139+ }
140+
141+ var release GitHubRelease
142+ if err := json .NewDecoder (resp .Body ).Decode (& release ); err != nil {
143+ return "" , fmt .Errorf ("failed to parse release info: %v" , err )
144+ }
145+
146+ return release .TagName , nil
147+ }
148+
149+ // isVersionCurrent checks if current version is the same as latest
150+ func isVersionCurrent (current , latest string ) bool {
151+ // Normalize versions by removing 'v' prefix if present
152+ current = strings .TrimPrefix (current , "v" )
153+ latest = strings .TrimPrefix (latest , "v" )
154+
155+ // For development builds, always consider outdated unless forced
156+ if current == "dev" {
157+ return false
158+ }
159+
160+ return current == latest
161+ }
162+
163+ // performUpgrade downloads and runs the install script
164+ func performUpgrade (version string ) error {
165+ color .New (color .FgBlue ).Printf ("⧗ upgrading to %s...\n " , version )
166+
167+ // Download install script
168+ installScript , err := downloadInstallScript ()
169+ if err != nil {
170+ return fmt .Errorf ("failed to download installer: %v" , err )
171+ }
172+
173+ // Create temporary file for install script
174+ tmpFile , err := os .CreateTemp ("" , "asyncstatus-install-*.sh" )
175+ if err != nil {
176+ return fmt .Errorf ("failed to create temp file: %v" , err )
177+ }
178+ defer os .Remove (tmpFile .Name ())
179+ defer tmpFile .Close ()
180+
181+ // Write install script to temp file
182+ if _ , err := tmpFile .Write (installScript ); err != nil {
183+ return fmt .Errorf ("failed to write install script: %v" , err )
184+ }
185+ tmpFile .Close ()
186+
187+ // Make script executable
188+ if err := os .Chmod (tmpFile .Name (), 0755 ); err != nil {
189+ return fmt .Errorf ("failed to make install script executable: %v" , err )
190+ }
191+
192+ // Get current install directory
193+ installDir , err := getCurrentInstallDir ()
194+ if err != nil {
195+ return fmt .Errorf ("failed to determine install directory: %v" , err )
196+ }
197+
198+ // Run install script with version (suppress output for cleaner experience)
199+ cmd := exec .Command ("bash" , tmpFile .Name (), "--version" , version )
200+ cmd .Env = append (os .Environ (), "INSTALL_DIR=" + installDir )
201+
202+ if err := cmd .Run (); err != nil {
203+ return fmt .Errorf ("install script failed: %v" , err )
204+ }
205+
206+ color .New (color .FgGreen ).Printf ("⧗ upgraded to %s\n " , version )
207+ return nil
208+ }
209+
210+ // downloadInstallScript downloads the install script from GitHub
211+ func downloadInstallScript () ([]byte , error ) {
212+ const installScriptURL = "https://raw.githubusercontent.com/AsyncStatus/asyncstatus/main/apps/cli/install.sh"
213+
214+ client := & http.Client {
215+ Timeout : 30 * time .Second ,
216+ }
217+
218+ req , err := http .NewRequest ("GET" , installScriptURL , nil )
219+ if err != nil {
220+ return nil , fmt .Errorf ("failed to create request: %v" , err )
221+ }
222+
223+ req .Header .Set ("User-Agent" , "AsyncStatus-CLI/" + Version )
224+
225+ resp , err := client .Do (req )
226+ if err != nil {
227+ return nil , fmt .Errorf ("failed to download install script: %v" , err )
228+ }
229+ defer resp .Body .Close ()
230+
231+ if resp .StatusCode != 200 {
232+ return nil , fmt .Errorf ("failed to download install script (status %d)" , resp .StatusCode )
233+ }
234+
235+ return io .ReadAll (resp .Body )
236+ }
237+
238+ // getCurrentInstallDir determines where the current binary is installed
239+ func getCurrentInstallDir () (string , error ) {
240+ // Get the path of the current executable
241+ execPath , err := os .Executable ()
242+ if err != nil {
243+ return "" , fmt .Errorf ("failed to get executable path: %v" , err )
244+ }
245+
246+ // Get the directory containing the executable
247+ installDir := strings .TrimSuffix (execPath , "/asyncstatus" )
248+
249+ // Handle cases where binary might be symlinked (like aliases)
250+ if strings .HasSuffix (execPath , "/⧗" ) || strings .HasSuffix (execPath , "/async" ) {
251+ // These are aliases, get the directory they're in
252+ installDir = strings .TrimSuffix (strings .TrimSuffix (execPath , "/⧗" ), "/async" )
253+ }
254+
255+ // Default fallback
256+ if installDir == "" {
257+ homeDir , err := os .UserHomeDir ()
258+ if err != nil {
259+ return "" , fmt .Errorf ("failed to get home directory: %v" , err )
260+ }
261+ installDir = homeDir + "/.asyncstatus/cli"
262+ }
263+
264+ return installDir , nil
265+ }
0 commit comments