Skip to content

Commit e9a3a53

Browse files
authored
feat(cli): add version management and upgrade command (#79)
* feat(cli): add version management and upgrade command * test(cli): add comprehensive tests for version comparison and fetching latest version * test(cli): enhance error handling in version tests by checking encoder errors
1 parent 7962263 commit e9a3a53

File tree

5 files changed

+786
-16
lines changed

5 files changed

+786
-16
lines changed
Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ A lightweight command-line tool for quickly creating Acontext projects with temp
88
- 🌐 **Multi-Language**: Support for Python and TypeScript
99
- 🐳 **Docker Ready**: One-command Docker Compose deployment
1010
- 🔧 **Auto Git**: Automatic Git repository initialization
11+
- 🔄 **Auto Update**: Automatic version checking and one-command upgrade
1112
- 🎯 **Simple**: Minimal configuration, maximum productivity
1213

1314
## Installation
@@ -66,26 +67,26 @@ acontext docker down
6667
### Version Management
6768

6869
```bash
69-
# Check version
70+
# Check version (automatically checks for updates)
7071
acontext version
7172

72-
# Check for updates
73-
acontext version check
74-
75-
# Auto-update
76-
acontext version check --upgrade
73+
# Upgrade to the latest version
74+
acontext upgrade
7775
```
7876

77+
The CLI automatically checks for updates after each command execution. If a new version is available, you'll see a notification prompting you to run `acontext upgrade`.
78+
7979
## Development Status
8080

81-
**🎯 Current Progress**: Production Ready (~92% complete)
81+
**🎯 Current Progress**: Production Ready (~95% complete)
8282
**✅ Completed**:
8383
- ✅ Interactive project creation
8484
- ✅ Multi-language template support (Python/TypeScript)
8585
- ✅ Dynamic template discovery from repository
8686
- ✅ Git repository initialization
8787
- ✅ Docker Compose integration
8888
- ✅ One-command deployment
89+
- ✅ Version checking and auto-update
8990
- ✅ CI/CD with GitHub Actions
9091
- ✅ Automated releases with GoReleaser
9192
- ✅ Comprehensive unit tests
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
10+
"github.com/memodb-io/Acontext/acontext-cli/internal/version"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var UpgradeCmd = &cobra.Command{
15+
Use: "upgrade",
16+
Short: "Upgrade Acontext CLI to the latest version",
17+
Long: `Upgrade Acontext CLI to the latest version.
18+
19+
This command downloads and installs the latest version of Acontext CLI
20+
by executing the installation script from install.acontext.io.
21+
22+
The upgrade process:
23+
1. Checks for the latest available version
24+
2. Downloads the installation script
25+
3. Executes the script to upgrade the CLI
26+
27+
Note: This command requires sudo privileges on most systems.
28+
`,
29+
RunE: runUpgrade,
30+
}
31+
32+
// VersionKey is the context key for storing version
33+
type VersionKey string
34+
35+
const versionKey VersionKey = "version"
36+
37+
// SetVersion sets the version in the command context
38+
func SetVersion(cmd *cobra.Command, v string) {
39+
ctx := cmd.Context()
40+
if ctx == nil {
41+
ctx = cmd.Root().Context()
42+
}
43+
ctx = context.WithValue(ctx, versionKey, v)
44+
cmd.SetContext(ctx)
45+
}
46+
47+
// GetVersion gets the version from the command context
48+
func GetVersion(cmd *cobra.Command) string {
49+
ctx := cmd.Context()
50+
if ctx == nil {
51+
ctx = cmd.Root().Context()
52+
}
53+
if v, ok := ctx.Value(versionKey).(string); ok {
54+
return v
55+
}
56+
// Fallback: try to get from binary
57+
return getCurrentVersionFallback()
58+
}
59+
60+
func runUpgrade(cmd *cobra.Command, args []string) error {
61+
fmt.Println("🔍 Checking for updates...")
62+
63+
currentVersion := GetVersion(cmd)
64+
hasUpdate, latestVersion, err := version.IsUpdateAvailable(currentVersion)
65+
if err != nil {
66+
return fmt.Errorf("failed to check for updates: %w", err)
67+
}
68+
69+
if !hasUpdate {
70+
fmt.Printf("✅ You are already using the latest version: %s\n", currentVersion)
71+
return nil
72+
}
73+
74+
fmt.Printf("📦 New version available: %s (current: %s)\n", latestVersion, currentVersion)
75+
fmt.Println()
76+
fmt.Println("🚀 Starting upgrade...")
77+
fmt.Println()
78+
79+
// Execute the installation script
80+
installScriptURL := "https://install.acontext.io"
81+
if err := executeInstallScript(installScriptURL); err != nil {
82+
return fmt.Errorf("upgrade failed: %w", err)
83+
}
84+
85+
fmt.Println()
86+
fmt.Println("✅ Upgrade complete!")
87+
fmt.Printf(" Run 'acontext version' to verify the new version\n")
88+
89+
return nil
90+
}
91+
92+
// getCurrentVersionFallback gets the current version by executing the version command
93+
// This is a fallback method when version is not available in context
94+
func getCurrentVersionFallback() string {
95+
// Try to get version from the binary itself
96+
versionCmd := exec.Command("acontext", "version")
97+
output, err := versionCmd.Output()
98+
if err != nil {
99+
return "unknown"
100+
}
101+
102+
// Parse output: "Acontext CLI version v0.0.1"
103+
versionStr := string(output)
104+
if idx := strings.Index(versionStr, "version "); idx != -1 {
105+
versionStr = versionStr[idx+8:]
106+
versionStr = strings.TrimSpace(versionStr)
107+
return versionStr
108+
}
109+
110+
return "unknown"
111+
}
112+
113+
// executeInstallScript downloads and executes the installation script
114+
func executeInstallScript(url string) error {
115+
// Determine the command to use (curl or wget)
116+
var cmd *exec.Cmd
117+
118+
if hasCommand("curl") {
119+
// Use curl to download and pipe to sh
120+
cmd = exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL %s | sh", url))
121+
} else if hasCommand("wget") {
122+
// Use wget to download and pipe to sh
123+
cmd = exec.Command("sh", "-c", fmt.Sprintf("wget -qO- %s | sh", url))
124+
} else {
125+
return fmt.Errorf("neither curl nor wget is available. Please install one of them to proceed")
126+
}
127+
128+
// Set up command to run in foreground with output
129+
cmd.Stdout = os.Stdout
130+
cmd.Stderr = os.Stderr
131+
cmd.Stdin = os.Stdin
132+
133+
// Execute the command
134+
if err := cmd.Run(); err != nil {
135+
return fmt.Errorf("installation script failed: %w", err)
136+
}
137+
138+
return nil
139+
}
140+
141+
// hasCommand checks if a command is available in PATH
142+
func hasCommand(cmd string) bool {
143+
_, err := exec.LookPath(cmd)
144+
return err == nil
145+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package version
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"sort"
8+
"strconv"
9+
"strings"
10+
"time"
11+
)
12+
13+
const (
14+
defaultGithubAPIURL = "https://api.github.com/repos/memodb-io/Acontext/releases"
15+
timeout = 10 * time.Second
16+
)
17+
18+
// githubAPIURL is a variable that can be overridden in tests
19+
var githubAPIURL = defaultGithubAPIURL
20+
21+
// Release represents a GitHub release
22+
type Release struct {
23+
TagName string `json:"tag_name"`
24+
}
25+
26+
// GetLatestVersion fetches the latest CLI version from GitHub releases
27+
// Returns the version string (e.g., "v0.0.1") without the "cli/" prefix
28+
func GetLatestVersion() (string, error) {
29+
client := &http.Client{
30+
Timeout: timeout,
31+
}
32+
33+
resp, err := client.Get(githubAPIURL)
34+
if err != nil {
35+
return "", fmt.Errorf("failed to fetch releases: %w", err)
36+
}
37+
defer func() {
38+
if closeErr := resp.Body.Close(); closeErr != nil {
39+
// Log error but don't fail the function
40+
_ = closeErr
41+
}
42+
}()
43+
44+
if resp.StatusCode != http.StatusOK {
45+
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
46+
}
47+
48+
var releases []Release
49+
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
50+
return "", fmt.Errorf("failed to decode response: %w", err)
51+
}
52+
53+
// Filter and extract CLI versions (format: cli/vX.X.X)
54+
var cliVersions []string
55+
for _, release := range releases {
56+
if strings.HasPrefix(release.TagName, "cli/v") {
57+
// Remove "cli/" prefix
58+
version := strings.TrimPrefix(release.TagName, "cli/")
59+
cliVersions = append(cliVersions, version)
60+
}
61+
}
62+
63+
if len(cliVersions) == 0 {
64+
return "", fmt.Errorf("no CLI releases found")
65+
}
66+
67+
// Sort versions using semantic version comparison
68+
sort.Slice(cliVersions, func(i, j int) bool {
69+
return CompareVersions(cliVersions[i], cliVersions[j]) > 0
70+
})
71+
72+
return cliVersions[0], nil
73+
}
74+
75+
// parseVersion parses a version string into major, minor, patch numbers
76+
// Returns (major, minor, patch, error)
77+
func parseVersion(version string) (int, int, int, error) {
78+
// Remove 'v' prefix if present
79+
version = strings.TrimPrefix(version, "v")
80+
81+
parts := strings.Split(version, ".")
82+
if len(parts) != 3 {
83+
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
84+
}
85+
86+
major, err := strconv.Atoi(parts[0])
87+
if err != nil {
88+
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
89+
}
90+
91+
minor, err := strconv.Atoi(parts[1])
92+
if err != nil {
93+
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
94+
}
95+
96+
patch, err := strconv.Atoi(parts[2])
97+
if err != nil {
98+
return 0, 0, 0, fmt.Errorf("invalid patch version: %s", parts[2])
99+
}
100+
101+
return major, minor, patch, nil
102+
}
103+
104+
// CompareVersions compares two version strings using semantic versioning
105+
// Returns:
106+
// - -1 if current < latest
107+
// - 0 if current == latest
108+
// - 1 if current > latest
109+
func CompareVersions(current, latest string) int {
110+
currentMajor, currentMinor, currentPatch, err1 := parseVersion(current)
111+
latestMajor, latestMinor, latestPatch, err2 := parseVersion(latest)
112+
113+
// If either version fails to parse, fall back to string comparison
114+
if err1 != nil || err2 != nil {
115+
current = strings.TrimPrefix(current, "v")
116+
latest = strings.TrimPrefix(latest, "v")
117+
if current < latest {
118+
return -1
119+
} else if current > latest {
120+
return 1
121+
}
122+
return 0
123+
}
124+
125+
// Compare major version
126+
if currentMajor < latestMajor {
127+
return -1
128+
} else if currentMajor > latestMajor {
129+
return 1
130+
}
131+
132+
// Compare minor version
133+
if currentMinor < latestMinor {
134+
return -1
135+
} else if currentMinor > latestMinor {
136+
return 1
137+
}
138+
139+
// Compare patch version
140+
if currentPatch < latestPatch {
141+
return -1
142+
} else if currentPatch > latestPatch {
143+
return 1
144+
}
145+
146+
return 0
147+
}
148+
149+
// IsUpdateAvailable checks if an update is available
150+
func IsUpdateAvailable(currentVersion string) (bool, string, error) {
151+
// Skip check for dev version
152+
if currentVersion == "dev" {
153+
return false, "", nil
154+
}
155+
156+
latestVersion, err := GetLatestVersion()
157+
if err != nil {
158+
return false, "", err
159+
}
160+
161+
// Compare versions using semantic versioning
162+
if CompareVersions(currentVersion, latestVersion) < 0 {
163+
return true, latestVersion, nil
164+
}
165+
166+
return false, "", nil
167+
}

0 commit comments

Comments
 (0)