Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A lightweight command-line tool for quickly creating Acontext projects with temp
- 🌐 **Multi-Language**: Support for Python and TypeScript
- 🐳 **Docker Ready**: One-command Docker Compose deployment
- 🔧 **Auto Git**: Automatic Git repository initialization
- 🔄 **Auto Update**: Automatic version checking and one-command upgrade
- 🎯 **Simple**: Minimal configuration, maximum productivity

## Installation
Expand Down Expand Up @@ -66,26 +67,26 @@ acontext docker down
### Version Management

```bash
# Check version
# Check version (automatically checks for updates)
acontext version

# Check for updates
acontext version check

# Auto-update
acontext version check --upgrade
# Upgrade to the latest version
acontext upgrade
```

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`.

## Development Status

**🎯 Current Progress**: Production Ready (~92% complete)
**🎯 Current Progress**: Production Ready (~95% complete)
**✅ Completed**:
- ✅ Interactive project creation
- ✅ Multi-language template support (Python/TypeScript)
- ✅ Dynamic template discovery from repository
- ✅ Git repository initialization
- ✅ Docker Compose integration
- ✅ One-command deployment
- ✅ Version checking and auto-update
- ✅ CI/CD with GitHub Actions
- ✅ Automated releases with GoReleaser
- ✅ Comprehensive unit tests
Expand Down
145 changes: 145 additions & 0 deletions src/client/acontext-cli/cmd/upgrade.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package cmd

import (
"context"
"fmt"
"os"
"os/exec"
"strings"

"github.com/memodb-io/Acontext/acontext-cli/internal/version"
"github.com/spf13/cobra"
)

var UpgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Upgrade Acontext CLI to the latest version",
Long: `Upgrade Acontext CLI to the latest version.

This command downloads and installs the latest version of Acontext CLI
by executing the installation script from install.acontext.io.

The upgrade process:
1. Checks for the latest available version
2. Downloads the installation script
3. Executes the script to upgrade the CLI

Note: This command requires sudo privileges on most systems.
`,
RunE: runUpgrade,
}

// VersionKey is the context key for storing version
type VersionKey string

const versionKey VersionKey = "version"

// SetVersion sets the version in the command context
func SetVersion(cmd *cobra.Command, v string) {
ctx := cmd.Context()
if ctx == nil {
ctx = cmd.Root().Context()
}
ctx = context.WithValue(ctx, versionKey, v)
cmd.SetContext(ctx)
}

// GetVersion gets the version from the command context
func GetVersion(cmd *cobra.Command) string {
ctx := cmd.Context()
if ctx == nil {
ctx = cmd.Root().Context()
}
if v, ok := ctx.Value(versionKey).(string); ok {
return v
}
// Fallback: try to get from binary
return getCurrentVersionFallback()
}

func runUpgrade(cmd *cobra.Command, args []string) error {
fmt.Println("🔍 Checking for updates...")

currentVersion := GetVersion(cmd)
hasUpdate, latestVersion, err := version.IsUpdateAvailable(currentVersion)
if err != nil {
return fmt.Errorf("failed to check for updates: %w", err)
}

if !hasUpdate {
fmt.Printf("✅ You are already using the latest version: %s\n", currentVersion)
return nil
}

fmt.Printf("📦 New version available: %s (current: %s)\n", latestVersion, currentVersion)
fmt.Println()
fmt.Println("🚀 Starting upgrade...")
fmt.Println()

// Execute the installation script
installScriptURL := "https://install.acontext.io"
if err := executeInstallScript(installScriptURL); err != nil {
return fmt.Errorf("upgrade failed: %w", err)
}

fmt.Println()
fmt.Println("✅ Upgrade complete!")
fmt.Printf(" Run 'acontext version' to verify the new version\n")

return nil
}

// getCurrentVersionFallback gets the current version by executing the version command
// This is a fallback method when version is not available in context
func getCurrentVersionFallback() string {
// Try to get version from the binary itself
versionCmd := exec.Command("acontext", "version")
output, err := versionCmd.Output()
if err != nil {
return "unknown"
}

// Parse output: "Acontext CLI version v0.0.1"
versionStr := string(output)
if idx := strings.Index(versionStr, "version "); idx != -1 {
versionStr = versionStr[idx+8:]
versionStr = strings.TrimSpace(versionStr)
return versionStr
}

return "unknown"
}

// executeInstallScript downloads and executes the installation script
func executeInstallScript(url string) error {
// Determine the command to use (curl or wget)
var cmd *exec.Cmd

if hasCommand("curl") {
// Use curl to download and pipe to sh
cmd = exec.Command("sh", "-c", fmt.Sprintf("curl -fsSL %s | sh", url))
} else if hasCommand("wget") {
// Use wget to download and pipe to sh
cmd = exec.Command("sh", "-c", fmt.Sprintf("wget -qO- %s | sh", url))
} else {
return fmt.Errorf("neither curl nor wget is available. Please install one of them to proceed")
}

// Set up command to run in foreground with output
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin

// Execute the command
if err := cmd.Run(); err != nil {
return fmt.Errorf("installation script failed: %w", err)
}

return nil
}

// hasCommand checks if a command is available in PATH
func hasCommand(cmd string) bool {
_, err := exec.LookPath(cmd)
return err == nil
}
167 changes: 167 additions & 0 deletions src/client/acontext-cli/internal/version/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package version

import (
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)

const (
defaultGithubAPIURL = "https://api.github.com/repos/memodb-io/Acontext/releases"
timeout = 10 * time.Second
)

// githubAPIURL is a variable that can be overridden in tests
var githubAPIURL = defaultGithubAPIURL

// Release represents a GitHub release
type Release struct {
TagName string `json:"tag_name"`
}

// GetLatestVersion fetches the latest CLI version from GitHub releases
// Returns the version string (e.g., "v0.0.1") without the "cli/" prefix
func GetLatestVersion() (string, error) {
client := &http.Client{
Timeout: timeout,
}

resp, err := client.Get(githubAPIURL)
if err != nil {
return "", fmt.Errorf("failed to fetch releases: %w", err)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil {
// Log error but don't fail the function
_ = closeErr
}
}()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}

var releases []Release
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}

// Filter and extract CLI versions (format: cli/vX.X.X)
var cliVersions []string
for _, release := range releases {
if strings.HasPrefix(release.TagName, "cli/v") {
// Remove "cli/" prefix
version := strings.TrimPrefix(release.TagName, "cli/")
cliVersions = append(cliVersions, version)
}
}

if len(cliVersions) == 0 {
return "", fmt.Errorf("no CLI releases found")
}

// Sort versions using semantic version comparison
sort.Slice(cliVersions, func(i, j int) bool {
return CompareVersions(cliVersions[i], cliVersions[j]) > 0
})

return cliVersions[0], nil
}

// parseVersion parses a version string into major, minor, patch numbers
// Returns (major, minor, patch, error)
func parseVersion(version string) (int, int, int, error) {
// Remove 'v' prefix if present
version = strings.TrimPrefix(version, "v")

parts := strings.Split(version, ".")
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid version format: %s", version)
}

major, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid major version: %s", parts[0])
}

minor, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid minor version: %s", parts[1])
}

patch, err := strconv.Atoi(parts[2])
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid patch version: %s", parts[2])
}

return major, minor, patch, nil
}

// CompareVersions compares two version strings using semantic versioning
// Returns:
// - -1 if current < latest
// - 0 if current == latest
// - 1 if current > latest
func CompareVersions(current, latest string) int {
currentMajor, currentMinor, currentPatch, err1 := parseVersion(current)
latestMajor, latestMinor, latestPatch, err2 := parseVersion(latest)

// If either version fails to parse, fall back to string comparison
if err1 != nil || err2 != nil {
current = strings.TrimPrefix(current, "v")
latest = strings.TrimPrefix(latest, "v")
if current < latest {
return -1
} else if current > latest {
return 1
}
return 0
}

// Compare major version
if currentMajor < latestMajor {
return -1
} else if currentMajor > latestMajor {
return 1
}

// Compare minor version
if currentMinor < latestMinor {
return -1
} else if currentMinor > latestMinor {
return 1
}

// Compare patch version
if currentPatch < latestPatch {
return -1
} else if currentPatch > latestPatch {
return 1
}

return 0
}

// IsUpdateAvailable checks if an update is available
func IsUpdateAvailable(currentVersion string) (bool, string, error) {
// Skip check for dev version
if currentVersion == "dev" {
return false, "", nil
}

latestVersion, err := GetLatestVersion()
if err != nil {
return false, "", err
}

// Compare versions using semantic versioning
if CompareVersions(currentVersion, latestVersion) < 0 {
return true, latestVersion, nil
}

return false, "", nil
}
Loading
Loading