Skip to content

Commit 685bb9a

Browse files
committed
feat: upgrade
1 parent af7bf2b commit 685bb9a

4 files changed

Lines changed: 426 additions & 9 deletions

File tree

apps/cli/README.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ asyncstatus edit yesterday # Edit yesterday's status
134134
asyncstatus show yesterday # Show yesterday's status
135135
asyncstatus list # View recent updates
136136
asyncstatus undo # Remove last item
137+
asyncstatus upgrade # Check for updates and upgrade
137138
```
138139

139140
**Example daily workflow:**
@@ -186,6 +187,8 @@ $ asyncstatus show
186187
| `asyncstatus show [date]` | Show status for date | `asyncstatus show yesterday` |
187188
| `asyncstatus list [days]` | List recent updates | `asyncstatus list 7` |
188189
| `asyncstatus undo` | Remove last item | `asyncstatus undo` |
190+
| `asyncstatus upgrade` | Check for updates and upgrade | `asyncstatus upgrade` |
191+
| `asyncstatus version` | Show version and check for updates | `asyncstatus version` |
189192
| `asyncstatus login` | Login to account | `asyncstatus login` |
190193
| `asyncstatus logout` | Logout and clear token | `asyncstatus logout` |
191194

@@ -384,6 +387,68 @@ $ asyncstatus undo
384387
run: asyncstatus login first
385388
```
386389
390+
#### 🔄 Upgrade Command
391+
392+
Keep your CLI up to date with the built-in upgrade functionality:
393+
394+
```bash
395+
# Check for updates and upgrade if available
396+
$ asyncstatus upgrade
397+
⧗ upgrade to v1.1.0? [y/N]: y
398+
⧗ upgrading to v1.1.0...
399+
⧗ upgraded to v1.1.0
400+
401+
# Just check for updates without installing
402+
$ asyncstatus upgrade --check
403+
⧗ already on latest version
404+
405+
# When newer version is available
406+
$ asyncstatus upgrade --check
407+
⧗ newer version available: v1.1.0
408+
409+
# Force upgrade even if on latest version
410+
$ asyncstatus upgrade --force
411+
⧗ upgrading to v1.1.0...
412+
⧗ upgraded to v1.1.0
413+
```
414+
415+
**How it works:**
416+
- ✅ Checks GitHub releases for the latest version
417+
- ✅ Compares with your current version
418+
- ✅ Downloads and runs the install script automatically
419+
- ✅ Preserves your configuration and login state
420+
- ✅ Verifies the upgrade was successful
421+
- ✅ Works with all installation methods (user directory, system-wide, etc.)
422+
423+
#### 📋 Version Command
424+
425+
Shows version info and automatically checks for updates:
426+
427+
```bash
428+
# When update is available
429+
$ asyncstatus version
430+
asyncstatus version v1.0.0
431+
Build time: 2024-01-15T10:30:00Z
432+
Git commit: abc1234
433+
434+
Checking for updates... done
435+
→ Newer version available: v1.1.0
436+
Run 'asyncstatus upgrade' to update
437+
438+
# When on latest version
439+
$ asyncstatus version
440+
asyncstatus version v1.1.0
441+
Build time: 2024-01-16T14:20:00Z
442+
Git commit: def5678
443+
444+
Checking for updates... done
445+
✓ You're running the latest version
446+
447+
# Works with --version and -v flags
448+
$ asyncstatus --version
449+
$ asyncstatus -v
450+
```
451+
387452
#### 🔧 Editor Configuration
388453

389454
The CLI respects your editor preferences in this order:

apps/cli/cmd/root.go

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cmd
22

33
import (
4-
"fmt"
54
"os"
65

76
"github.com/fatih/color"
@@ -43,8 +42,14 @@ Examples:
4342
- https://github.com/asyncstatus/asyncstatus
4443
- https://github.com/asyncstatus/asyncstatus/issues
4544
- https://github.com/asyncstatus/asyncstatus/releases`,
46-
Version: Version,
4745
Args: cobra.MaximumNArgs(1),
46+
PreRun: func(cmd *cobra.Command, args []string) {
47+
// Handle --version flag by calling our custom version handler
48+
if versionFlag, _ := cmd.Flags().GetBool("version"); versionFlag {
49+
handleVersion()
50+
os.Exit(0)
51+
}
52+
},
4853
Run: func(cmd *cobra.Command, args []string) {
4954
// If no subcommand is provided but there's an argument,
5055
// treat it as a "done" status update
@@ -73,13 +78,7 @@ func Execute() {
7378
}
7479

7580
func init() {
76-
// Set custom version template
77-
versionTemplate := fmt.Sprintf(`{{printf "%%s version %%s\n" .Name .Version}}Build time: %s
78-
Git commit: %s
79-
`, BuildTime, GitCommit)
80-
rootCmd.SetVersionTemplate(versionTemplate)
81-
82-
// Custom version flag that shows build info
81+
// Custom version flag that shows build info and checks for updates
8382
rootCmd.Flags().BoolP("version", "v", false, "version for asyncstatus")
8483
}
8584

apps/cli/cmd/upgrade.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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

Comments
 (0)