From 868e888c34e58fb3ab8a9ab3d959db3c10d6ec71 Mon Sep 17 00:00:00 2001 From: Dejan Stefanoski Date: Wed, 4 Feb 2026 21:04:21 +0100 Subject: [PATCH] feat: add auto-upgrade system with /upgrade command Add automatic version checking and upgrade capability: - Check for updates on startup (async, non-blocking) - Detect install method (Homebrew, go install, curl script) - /upgrade command with subcommands (check, now, skip, changelog, status) - Skip versions user doesn't want to install - Configurable via upgrade.auto_check in config New package: internal/upgrade/ - types.go: ReleaseInfo, UpgradeState, CheckResult - checker.go: GitHub API version checking - installer.go: Binary download and installation - checker_test.go: Unit tests for version comparison --- README.md | 1 + cmd/repl.go | 22 ++ cmd/root.go | 8 + cmd/slash_completer.go | 7 + cmd/upgrade_cmd.go | 373 +++++++++++++++++++++++++++++++ config.example.yaml | 31 +++ internal/upgrade/checker.go | 324 +++++++++++++++++++++++++++ internal/upgrade/checker_test.go | 109 +++++++++ internal/upgrade/installer.go | 219 ++++++++++++++++++ internal/upgrade/types.go | 69 ++++++ 10 files changed, 1163 insertions(+) create mode 100644 cmd/upgrade_cmd.go create mode 100644 internal/upgrade/checker.go create mode 100644 internal/upgrade/checker_test.go create mode 100644 internal/upgrade/installer.go create mode 100644 internal/upgrade/types.go diff --git a/README.md b/README.md index 1f3a4e7..310a3ae 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ Full DevSecOps capabilities with audit logging: | `/undo` | Undo file modifications | | `/diff` | Show session changes | | `/tools` | List available tools | +| `/upgrade` | Check for and install updates | | `/help` | Show help | ## Configuration diff --git a/cmd/repl.go b/cmd/repl.go index bd9b87e..1fe4d11 100644 --- a/cmd/repl.go +++ b/cmd/repl.go @@ -23,6 +23,7 @@ import ( "github.com/tara-vision/taracode/internal/storage" "github.com/tara-vision/taracode/internal/tools" "github.com/tara-vision/taracode/internal/ui" + "github.com/tara-vision/taracode/internal/upgrade" "github.com/tara-vision/taracode/internal/watch" ) @@ -161,6 +162,14 @@ func startREPL() { } } + // Start async version check (non-blocking) + updateResultChan := make(chan *upgrade.CheckResult, 1) + if viper.GetBool("upgrade.auto_check") { + CheckForUpdateAsync(Version, updateResultChan) + } else { + close(updateResultChan) + } + // Initialize MCP manager if enabled var mcpManager *mcp.Manager mcpConfig := GetMCPConfig() @@ -226,6 +235,14 @@ func startREPL() { } defer rl.Close() + // Check for update result (non-blocking) + select { + case updateResult := <-updateResultChan: + ShowUpdateBanner(updateResult) + default: + // No result yet, continue without blocking + } + // Main REPL loop for { line, err := rl.Readline() @@ -552,6 +569,7 @@ func handleCommand(cmd string, workingDir string, asst **assistant.Assistant, ho fmt.Println(" /context --agents - Show per-agent context usage") fmt.Println(" /tools - List available AI tools") fmt.Println(" /usage - Show token usage statistics") + fmt.Println(" /upgrade - Check for and install updates") fmt.Println(" /help - Show this help message") fmt.Println(" exit - Exit Tara Code") fmt.Println() @@ -792,6 +810,10 @@ func handleCommand(cmd string, workingDir string, asst **assistant.Assistant, ho // Screen monitoring and analysis handleWatchCommand(args, *asst, watchMonitor, os.TempDir()) + case "/upgrade": + // Check for and install updates + handleUpgradeCommand(args) + default: fmt.Printf("Unknown command: %s\n", cmd) fmt.Println("Type '/help' for available commands.") diff --git a/cmd/root.go b/cmd/root.go index 712a720..cef4e86 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -254,6 +254,14 @@ func initConfig() { viper.SetDefault("memory.retention_days", 90) viper.SetDefault("memory.auto_capture", true) + // Upgrade (Auto-update) configuration defaults + // auto_check: Check for updates on startup (default: true) + // auto_upgrade: Automatically install updates without prompting (default: false) + // show_changelog: Show changelog when update is available (default: true) + viper.SetDefault("upgrade.auto_check", true) + viper.SetDefault("upgrade.auto_upgrade", false) + viper.SetDefault("upgrade.show_changelog", true) + viper.SetEnvPrefix("TARACODE") viper.AutomaticEnv() diff --git a/cmd/slash_completer.go b/cmd/slash_completer.go index 1d393f7..56d78fc 100644 --- a/cmd/slash_completer.go +++ b/cmd/slash_completer.go @@ -138,6 +138,13 @@ func GetSlashCommands() []SlashCommand { {"/tools", "List available AI tools"}, {"/usage", "Show token usage statistics"}, {"/help", "Show help message"}, + // Upgrade + {"/upgrade", "Check for and install updates"}, + {"/upgrade check", "Check for new version"}, + {"/upgrade now", "Upgrade to latest version"}, + {"/upgrade skip", "Skip the current available update"}, + {"/upgrade changelog", "Show full release notes"}, + {"/upgrade status", "Show upgrade state information"}, } } diff --git a/cmd/upgrade_cmd.go b/cmd/upgrade_cmd.go new file mode 100644 index 0000000..aaeffce --- /dev/null +++ b/cmd/upgrade_cmd.go @@ -0,0 +1,373 @@ +package cmd + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/manifoldco/promptui" + "github.com/tara-vision/taracode/internal/ui" + "github.com/tara-vision/taracode/internal/upgrade" +) + +var ( + upgradeHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#A78BFA")) + + upgradeVersionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2dd4bf")). + Bold(true) + + upgradeNewVersionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FCD34D")). + Bold(true) + + upgradeInfoStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#94A3B8")) + + upgradeSuccessStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2dd4bf")). + Bold(true) + + upgradeChangelogStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#93C5FD")). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#94A3B8")). + Padding(1, 2). + MarginTop(1). + MarginBottom(1) +) + +// handleUpgradeCommand handles the /upgrade command and subcommands +func handleUpgradeCommand(args []string) { + if len(args) == 0 { + // Default: check for updates + handleUpgradeCheck(true) + return + } + + subCmd := strings.ToLower(args[0]) + + switch subCmd { + case "check": + handleUpgradeCheck(true) + case "now": + handleUpgradeNow() + case "skip": + handleUpgradeSkip() + case "changelog": + handleUpgradeChangelog() + case "status": + handleUpgradeStatus() + case "help": + showUpgradeHelp() + default: + fmt.Printf("Unknown upgrade subcommand: %s\n", subCmd) + fmt.Println("Use '/upgrade help' for available commands") + fmt.Println() + } +} + +// handleUpgradeCheck checks for available updates +func handleUpgradeCheck(verbose bool) { + fmt.Println() + fmt.Println(upgradeHeaderStyle.Render("Checking for Updates")) + fmt.Println() + + checker := upgrade.NewChecker(Version) + result, err := checker.CheckForUpdate() + if err != nil { + fmt.Printf("%s Failed to check for updates: %v\n", ui.IconError, err) + fmt.Println() + return + } + + fmt.Printf(" Current version: %s\n", upgradeVersionStyle.Render(result.CurrentVersion)) + fmt.Printf(" Latest version: %s\n", upgradeNewVersionStyle.Render(result.LatestVersion)) + fmt.Printf(" Install method: %s\n", upgradeInfoStyle.Render(result.InstallMethod)) + fmt.Println() + + if !result.UpdateAvailable { + fmt.Printf("%s You're running the latest version!\n", ui.IconSuccess) + fmt.Println() + return + } + + if result.SkippedByUser { + fmt.Printf("%s Update available (you previously skipped this version)\n", ui.IconInfo) + fmt.Println(" Use '/upgrade now' to upgrade anyway") + fmt.Println() + return + } + + fmt.Printf("%s A new version is available!\n", ui.IconStar) + fmt.Println() + + // Show changelog preview + if result.Changelog != "" && verbose { + fmt.Println(upgradeInfoStyle.Render("Changelog:")) + changelog := truncateChangelog(result.Changelog, 500) + fmt.Println(upgradeChangelogStyle.Render(changelog)) + } + + // Show upgrade command + method := upgrade.InstallMethod(result.InstallMethod) + fmt.Println(" To upgrade, run:") + fmt.Printf(" %s\n", upgradeVersionStyle.Render(upgrade.GetUpgradeCommand(method))) + fmt.Println() + fmt.Println(" Or use '/upgrade now' to upgrade automatically") + fmt.Println() +} + +// handleUpgradeNow performs the upgrade +func handleUpgradeNow() { + fmt.Println() + fmt.Println(upgradeHeaderStyle.Render("Upgrading taracode")) + fmt.Println() + + checker := upgrade.NewChecker(Version) + result, err := checker.CheckForUpdate() + if err != nil { + fmt.Printf("%s Failed to check for updates: %v\n", ui.IconError, err) + fmt.Println() + return + } + + if !result.UpdateAvailable { + fmt.Printf("%s You're already running the latest version (%s)\n", + ui.IconSuccess, upgradeVersionStyle.Render(result.CurrentVersion)) + fmt.Println() + return + } + + fmt.Printf(" Upgrading from %s to %s\n", + upgradeVersionStyle.Render(result.CurrentVersion), + upgradeNewVersionStyle.Render(result.LatestVersion)) + fmt.Println() + + // Confirm upgrade + prompt := promptui.Prompt{ + Label: "Proceed with upgrade", + IsConfirm: true, + Default: "y", + } + + _, err = prompt.Run() + if err != nil { + fmt.Println("Upgrade cancelled") + fmt.Println() + return + } + + fmt.Println() + + // Perform upgrade + installer := upgrade.NewInstaller(checker) + if err := installer.Upgrade(result); err != nil { + fmt.Printf("%s Upgrade failed: %v\n", ui.IconError, err) + fmt.Println() + fmt.Println("You can try upgrading manually:") + method := upgrade.InstallMethod(result.InstallMethod) + fmt.Printf(" %s\n", upgrade.GetUpgradeCommand(method)) + fmt.Println() + return + } + + fmt.Println() + fmt.Printf("%s Successfully upgraded to %s!\n", + ui.IconSuccess, upgradeNewVersionStyle.Render(result.LatestVersion)) + fmt.Println() + fmt.Println(upgradeInfoStyle.Render("Please restart taracode to use the new version.")) + fmt.Println() +} + +// handleUpgradeSkip skips the current available update +func handleUpgradeSkip() { + checker := upgrade.NewChecker(Version) + result, err := checker.CheckForUpdate() + if err != nil { + fmt.Printf("%s Failed to check for updates: %v\n", ui.IconError, err) + fmt.Println() + return + } + + if !result.UpdateAvailable { + fmt.Printf("%s No update available to skip\n", ui.IconInfo) + fmt.Println() + return + } + + if err := checker.SkipVersion(result.LatestVersion); err != nil { + fmt.Printf("%s Failed to skip version: %v\n", ui.IconError, err) + fmt.Println() + return + } + + fmt.Printf("%s Skipped version %s\n", ui.IconSuccess, result.LatestVersion) + fmt.Println(" You won't be notified about this version again") + fmt.Println(" Use '/upgrade now' to upgrade anyway") + fmt.Println() +} + +// handleUpgradeChangelog shows the full changelog +func handleUpgradeChangelog() { + fmt.Println() + fmt.Println(upgradeHeaderStyle.Render("Release Changelog")) + fmt.Println() + + checker := upgrade.NewChecker(Version) + result, err := checker.CheckForUpdate() + if err != nil { + fmt.Printf("%s Failed to fetch changelog: %v\n", ui.IconError, err) + fmt.Println() + return + } + + if result.ReleaseInfo == nil { + fmt.Println("No changelog available") + fmt.Println() + return + } + + fmt.Printf(" Version: %s\n", upgradeNewVersionStyle.Render(result.LatestVersion)) + if !result.ReleaseInfo.PublishedAt.IsZero() { + fmt.Printf(" Released: %s\n", upgradeInfoStyle.Render(result.ReleaseInfo.PublishedAt.Format("2006-01-02"))) + } + fmt.Println() + + if result.Changelog != "" { + fmt.Println(result.Changelog) + } else { + fmt.Println("No changelog available") + } + + if result.ReleaseInfo.HTMLURL != "" { + fmt.Println() + fmt.Printf(" View on GitHub: %s\n", upgradeInfoStyle.Render(result.ReleaseInfo.HTMLURL)) + } + fmt.Println() +} + +// handleUpgradeStatus shows upgrade state +func handleUpgradeStatus() { + fmt.Println() + fmt.Println(upgradeHeaderStyle.Render("Upgrade Status")) + fmt.Println() + + checker := upgrade.NewChecker(Version) + state := checker.LoadState() + + fmt.Printf(" Current version: %s\n", upgradeVersionStyle.Render(Version)) + fmt.Printf(" Install method: %s\n", upgradeInfoStyle.Render(string(checker.DetectInstallMethod()))) + + if !state.LastCheckTime.IsZero() { + fmt.Printf(" Last check: %s\n", upgradeInfoStyle.Render(state.LastCheckTime.Format("2006-01-02 15:04:05"))) + } else { + fmt.Printf(" Last check: %s\n", upgradeInfoStyle.Render("never")) + } + + if state.LastCheckVersion != "" { + fmt.Printf(" Latest known: %s\n", upgradeInfoStyle.Render(state.LastCheckVersion)) + } + + if state.SkippedVersion != "" { + fmt.Printf(" Skipped version: %s\n", upgradeInfoStyle.Render(state.SkippedVersion)) + } + + fmt.Println() +} + +// showUpgradeHelp displays help for the upgrade command +func showUpgradeHelp() { + fmt.Println() + fmt.Println(upgradeHeaderStyle.Render("Upgrade Commands")) + fmt.Println() + fmt.Println(" /upgrade Check for available updates (default)") + fmt.Println(" /upgrade check Check for available updates") + fmt.Println(" /upgrade now Download and install the latest version") + fmt.Println(" /upgrade skip Skip the current available update") + fmt.Println(" /upgrade changelog Show full release notes") + fmt.Println(" /upgrade status Show upgrade state information") + fmt.Println(" /upgrade help Show this help message") + fmt.Println() +} + +// ShowUpdateBanner displays a banner when an update is available (called at startup) +func ShowUpdateBanner(result *upgrade.CheckResult) { + if result == nil || !result.UpdateAvailable || result.SkippedByUser { + return + } + + fmt.Println() + bannerStyle := lipgloss.NewStyle(). + BorderStyle(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#FCD34D")). + Padding(0, 2) + + content := fmt.Sprintf( + "%s Update available: %s %s %s\n Run '/upgrade' for details or '/upgrade now' to install", + ui.IconStar, + upgradeVersionStyle.Render(result.CurrentVersion), + upgradeInfoStyle.Render("->"), + upgradeNewVersionStyle.Render(result.LatestVersion), + ) + + fmt.Println(bannerStyle.Render(content)) + fmt.Println() +} + +// CheckForUpdateAsync checks for updates in the background +// Returns the result via the provided channel +func CheckForUpdateAsync(currentVersion string, resultChan chan<- *upgrade.CheckResult) { + go func() { + checker := upgrade.NewChecker(currentVersion) + + // Only check if enough time has passed (24 hours by default) + if !checker.ShouldCheck(24 * time.Hour) { + // Load last known state if we have a recent check + state := checker.LoadState() + if state.LastCheckVersion != "" { + // Check if update is available based on stored state + current := strings.TrimPrefix(currentVersion, "v") + latest := strings.TrimPrefix(state.LastCheckVersion, "v") + if upgrade.CompareVersions(latest, current) > 0 { + resultChan <- &upgrade.CheckResult{ + CurrentVersion: currentVersion, + LatestVersion: state.LastCheckVersion, + UpdateAvailable: true, + SkippedByUser: state.SkippedVersion == state.LastCheckVersion, + } + return + } + } + resultChan <- nil + return + } + + result, err := checker.CheckForUpdate() + if err != nil { + resultChan <- nil + return + } + + resultChan <- result + }() +} + +// truncateChangelog truncates the changelog to a reasonable length +func truncateChangelog(changelog string, maxLen int) string { + if len(changelog) <= maxLen { + return changelog + } + + // Try to cut at a newline + truncated := changelog[:maxLen] + lastNewline := strings.LastIndex(truncated, "\n") + if lastNewline > maxLen/2 { + truncated = truncated[:lastNewline] + } + + return truncated + "\n..." +} diff --git a/config.example.yaml b/config.example.yaml index 3a014d3..4bae98e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -89,3 +89,34 @@ mcp: # args: ["-y", "@modelcontextprotocol/server-postgres"] # env: # POSTGRES_CONNECTION_STRING: "${DATABASE_URL}" + +# Memory (Persistent Project Knowledge) Configuration +memory: + # Enable or disable memory feature (default: true) + enabled: true + + # Maximum number of memories per project (default: 500) + max_memories: 500 + + # Maximum tokens to inject into prompt (default: 2000) + max_context_tokens: 2000 + + # Auto-cleanup memories not used in N days (default: 90) + retention_days: 90 + + # Detect and suggest memories from conversation (default: true) + auto_capture: true + +# Upgrade (Auto-update) Configuration +upgrade: + # Check for updates on startup (default: true) + # Set to false to disable automatic update checks + auto_check: true + + # Automatically install updates without prompting (default: false) + # When true, updates are installed immediately without confirmation + # Use with caution - recommended to keep this false + auto_upgrade: false + + # Show changelog when update is available (default: true) + show_changelog: true diff --git a/internal/upgrade/checker.go b/internal/upgrade/checker.go new file mode 100644 index 0000000..08fad22 --- /dev/null +++ b/internal/upgrade/checker.go @@ -0,0 +1,324 @@ +package upgrade + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + GitHubRepo = "tara-vision/taracode" + GitHubAPIURL = "https://api.github.com/repos/" + GitHubRepo + "/releases/latest" + StateFileName = "upgrade_state.json" + RequestTimeout = 10 * time.Second +) + +// Checker handles version checking +type Checker struct { + currentVersion string + stateDir string + httpClient *http.Client +} + +// NewChecker creates a new version checker +func NewChecker(currentVersion string) *Checker { + homeDir, _ := os.UserHomeDir() + stateDir := filepath.Join(homeDir, ".taracode") + + return &Checker{ + currentVersion: currentVersion, + stateDir: stateDir, + httpClient: &http.Client{ + Timeout: RequestTimeout, + }, + } +} + +// CheckForUpdate checks if a new version is available +func (c *Checker) CheckForUpdate() (*CheckResult, error) { + // Fetch latest release info from GitHub + releaseInfo, err := c.fetchLatestRelease() + if err != nil { + return nil, fmt.Errorf("failed to check for updates: %w", err) + } + + latestVersion := strings.TrimPrefix(releaseInfo.TagName, "v") + currentVersion := strings.TrimPrefix(c.currentVersion, "v") + + // Check if user skipped this version + state := c.LoadState() + skippedByUser := state.SkippedVersion == releaseInfo.TagName + + result := &CheckResult{ + CurrentVersion: c.currentVersion, + LatestVersion: releaseInfo.TagName, + UpdateAvailable: isNewerVersion(latestVersion, currentVersion), + ReleaseInfo: releaseInfo, + Changelog: releaseInfo.Body, + DownloadURL: c.getDownloadURL(releaseInfo), + InstallMethod: string(c.DetectInstallMethod()), + SkippedByUser: skippedByUser, + } + + // Update last check time + state.LastCheckTime = time.Now() + state.LastCheckVersion = releaseInfo.TagName + c.SaveState(state) + + return result, nil +} + +// fetchLatestRelease fetches the latest release info from GitHub API +func (c *Checker) fetchLatestRelease() (*ReleaseInfo, error) { + req, err := http.NewRequest("GET", GitHubAPIURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("User-Agent", "taracode/"+c.currentVersion) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned status %d", resp.StatusCode) + } + + var release ReleaseInfo + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} + +// getDownloadURL returns the appropriate download URL for the current platform +func (c *Checker) getDownloadURL(release *ReleaseInfo) string { + osName := runtime.GOOS + arch := runtime.GOARCH + + // Map Go arch names to binary names + if arch == "amd64" { + arch = "amd64" + } else if arch == "arm64" { + arch = "arm64" + } + + binaryName := fmt.Sprintf("taracode-%s-%s", osName, arch) + + for _, asset := range release.Assets { + if asset.Name == binaryName { + return asset.BrowserDownloadURL + } + } + + return "" +} + +// DetectInstallMethod attempts to detect how taracode was installed +func (c *Checker) DetectInstallMethod() InstallMethod { + // Check saved state first + state := c.LoadState() + if state.InstallMethod != "" { + return InstallMethod(state.InstallMethod) + } + + // Check if installed via Homebrew + if c.isHomebrewInstall() { + return InstallMethodHomebrew + } + + // Check if installed via go install (in GOPATH/bin or GOBIN) + if c.isGoInstall() { + return InstallMethodGo + } + + // Check if binary is in /usr/local/bin (typical curl install location) + execPath, err := os.Executable() + if err == nil { + if strings.HasPrefix(execPath, "/usr/local/bin") { + return InstallMethodCurl + } + } + + return InstallMethodUnknown +} + +// isHomebrewInstall checks if taracode was installed via Homebrew +func (c *Checker) isHomebrewInstall() bool { + // Check if brew command exists + _, err := exec.LookPath("brew") + if err != nil { + return false + } + + // Check if taracode is in Homebrew's Cellar + cmd := exec.Command("brew", "list", "--versions", "taracode") + output, err := cmd.Output() + if err != nil { + return false + } + + return len(output) > 0 +} + +// isGoInstall checks if taracode was installed via go install +func (c *Checker) isGoInstall() bool { + execPath, err := os.Executable() + if err != nil { + return false + } + + // Check GOBIN + gobin := os.Getenv("GOBIN") + if gobin != "" && strings.HasPrefix(execPath, gobin) { + return true + } + + // Check GOPATH/bin + gopath := os.Getenv("GOPATH") + if gopath == "" { + homeDir, _ := os.UserHomeDir() + gopath = filepath.Join(homeDir, "go") + } + goBin := filepath.Join(gopath, "bin") + if strings.HasPrefix(execPath, goBin) { + return true + } + + return false +} + +// ShouldCheck returns true if enough time has passed since the last check +func (c *Checker) ShouldCheck(interval time.Duration) bool { + state := c.LoadState() + if state.LastCheckTime.IsZero() { + return true + } + return time.Since(state.LastCheckTime) >= interval +} + +// LoadState loads the upgrade state from disk +func (c *Checker) LoadState() *UpgradeState { + statePath := filepath.Join(c.stateDir, StateFileName) + data, err := os.ReadFile(statePath) + if err != nil { + return &UpgradeState{} + } + + var state UpgradeState + if err := json.Unmarshal(data, &state); err != nil { + return &UpgradeState{} + } + + return &state +} + +// SaveState saves the upgrade state to disk +func (c *Checker) SaveState(state *UpgradeState) error { + if err := os.MkdirAll(c.stateDir, 0755); err != nil { + return err + } + + statePath := filepath.Join(c.stateDir, StateFileName) + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(statePath, data, 0644) +} + +// SkipVersion marks a version as skipped by the user +func (c *Checker) SkipVersion(version string) error { + state := c.LoadState() + state.SkippedVersion = version + return c.SaveState(state) +} + +// ClearSkippedVersion clears the skipped version +func (c *Checker) ClearSkippedVersion() error { + state := c.LoadState() + state.SkippedVersion = "" + return c.SaveState(state) +} + +// SetInstallMethod saves the detected/specified install method +func (c *Checker) SetInstallMethod(method InstallMethod) error { + state := c.LoadState() + state.InstallMethod = string(method) + return c.SaveState(state) +} + +// isNewerVersion compares two semantic versions (without 'v' prefix) +// Returns true if latest > current +func isNewerVersion(latest, current string) bool { + // Handle dev version - always consider updates available + if current == "dev" || current == "" { + return true + } + + latestParts := parseVersion(latest) + currentParts := parseVersion(current) + + for i := 0; i < 3; i++ { + if latestParts[i] > currentParts[i] { + return true + } + if latestParts[i] < currentParts[i] { + return false + } + } + + return false +} + +// parseVersion parses a semantic version string into [major, minor, patch] +func parseVersion(version string) [3]int { + parts := strings.Split(version, ".") + var result [3]int + + for i := 0; i < 3 && i < len(parts); i++ { + // Remove any suffix (like -beta, -rc1) + numStr := strings.Split(parts[i], "-")[0] + num, _ := strconv.Atoi(numStr) + result[i] = num + } + + return result +} + +// CompareVersions compares two versions and returns: +// -1 if v1 < v2 +// +// 0 if v1 == v2 +// 1 if v1 > v2 +func CompareVersions(v1, v2 string) int { + v1 = strings.TrimPrefix(v1, "v") + v2 = strings.TrimPrefix(v2, "v") + + parts1 := parseVersion(v1) + parts2 := parseVersion(v2) + + for i := 0; i < 3; i++ { + if parts1[i] < parts2[i] { + return -1 + } + if parts1[i] > parts2[i] { + return 1 + } + } + + return 0 +} diff --git a/internal/upgrade/checker_test.go b/internal/upgrade/checker_test.go new file mode 100644 index 0000000..bb22b0c --- /dev/null +++ b/internal/upgrade/checker_test.go @@ -0,0 +1,109 @@ +package upgrade + +import "testing" + +func TestParseVersion(t *testing.T) { + tests := []struct { + input string + expected [3]int + }{ + {"1.0.0", [3]int{1, 0, 0}}, + {"1.2.3", [3]int{1, 2, 3}}, + {"0.1.0", [3]int{0, 1, 0}}, + {"10.20.30", [3]int{10, 20, 30}}, + {"1.0.0-beta", [3]int{1, 0, 0}}, + {"2.1.0-rc1", [3]int{2, 1, 0}}, + {"1", [3]int{1, 0, 0}}, + {"1.2", [3]int{1, 2, 0}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := parseVersion(tt.input) + if result != tt.expected { + t.Errorf("parseVersion(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + name string + latest string + current string + expected bool + }{ + {"newer major", "2.0.0", "1.0.0", true}, + {"newer minor", "1.1.0", "1.0.0", true}, + {"newer patch", "1.0.1", "1.0.0", true}, + {"same version", "1.0.0", "1.0.0", false}, + {"older major", "1.0.0", "2.0.0", false}, + {"older minor", "1.0.0", "1.1.0", false}, + {"older patch", "1.0.0", "1.0.1", false}, + {"dev version", "1.0.0", "dev", true}, + {"empty current", "1.0.0", "", true}, + {"complex newer", "1.2.3", "1.2.2", true}, + {"complex same", "1.2.3", "1.2.3", false}, + {"complex older", "1.2.2", "1.2.3", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNewerVersion(tt.latest, tt.current) + if result != tt.expected { + t.Errorf("isNewerVersion(%q, %q) = %v, want %v", tt.latest, tt.current, result, tt.expected) + } + }) + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + v1 string + v2 string + expected int + }{ + {"v1 greater major", "v2.0.0", "v1.0.0", 1}, + {"v1 greater minor", "v1.1.0", "v1.0.0", 1}, + {"v1 greater patch", "v1.0.1", "v1.0.0", 1}, + {"equal versions", "v1.0.0", "v1.0.0", 0}, + {"equal without v", "1.0.0", "1.0.0", 0}, + {"v1 less major", "v1.0.0", "v2.0.0", -1}, + {"v1 less minor", "v1.0.0", "v1.1.0", -1}, + {"v1 less patch", "v1.0.0", "v1.0.1", -1}, + {"mixed v prefix", "v1.0.0", "1.0.0", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CompareVersions(tt.v1, tt.v2) + if result != tt.expected { + t.Errorf("CompareVersions(%q, %q) = %v, want %v", tt.v1, tt.v2, result, tt.expected) + } + }) + } +} + +func TestGetUpgradeCommand(t *testing.T) { + tests := []struct { + method InstallMethod + expected string + }{ + {InstallMethodHomebrew, "brew upgrade taracode"}, + {InstallMethodGo, "go install github.com/tara-vision/taracode@latest"}, + {InstallMethodCurl, "curl -fsSL https://code.tara.vision/install.sh | bash"}, + {InstallMethodManual, "curl -fsSL https://code.tara.vision/install.sh | bash"}, + {InstallMethodUnknown, "curl -fsSL https://code.tara.vision/install.sh | bash"}, + } + + for _, tt := range tests { + t.Run(string(tt.method), func(t *testing.T) { + result := GetUpgradeCommand(tt.method) + if result != tt.expected { + t.Errorf("GetUpgradeCommand(%q) = %q, want %q", tt.method, result, tt.expected) + } + }) + } +} diff --git a/internal/upgrade/installer.go b/internal/upgrade/installer.go new file mode 100644 index 0000000..5f57fd0 --- /dev/null +++ b/internal/upgrade/installer.go @@ -0,0 +1,219 @@ +package upgrade + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// Installer handles the upgrade process +type Installer struct { + checker *Checker + httpClient *http.Client +} + +// NewInstaller creates a new installer +func NewInstaller(checker *Checker) *Installer { + return &Installer{ + checker: checker, + httpClient: &http.Client{ + Timeout: 5 * time.Minute, // Longer timeout for downloads + }, + } +} + +// Upgrade performs the upgrade based on install method +func (i *Installer) Upgrade(result *CheckResult) error { + method := InstallMethod(result.InstallMethod) + + switch method { + case InstallMethodHomebrew: + return i.upgradeViaHomebrew() + case InstallMethodGo: + return i.upgradeViaGo() + case InstallMethodCurl, InstallMethodManual, InstallMethodUnknown: + return i.upgradeViaBinary(result.DownloadURL) + default: + return i.upgradeViaBinary(result.DownloadURL) + } +} + +// upgradeViaHomebrew upgrades using Homebrew +func (i *Installer) upgradeViaHomebrew() error { + fmt.Println("Upgrading via Homebrew...") + + // First update the tap + cmd := exec.Command("brew", "update") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("brew update failed: %w", err) + } + + // Then upgrade taracode + cmd = exec.Command("brew", "upgrade", "taracode") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("brew upgrade failed: %w", err) + } + + return nil +} + +// upgradeViaGo upgrades using go install +func (i *Installer) upgradeViaGo() error { + fmt.Println("Upgrading via go install...") + + cmd := exec.Command("go", "install", "github.com/tara-vision/taracode@latest") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + + if err := cmd.Run(); err != nil { + return fmt.Errorf("go install failed: %w", err) + } + + return nil +} + +// upgradeViaBinary downloads and installs the binary directly +func (i *Installer) upgradeViaBinary(downloadURL string) error { + if downloadURL == "" { + return fmt.Errorf("no download URL available for %s/%s", runtime.GOOS, runtime.GOARCH) + } + + fmt.Printf("Downloading from %s...\n", downloadURL) + + // Get the current executable path + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %w", err) + } + + // Resolve symlinks + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return fmt.Errorf("failed to resolve symlinks: %w", err) + } + + // Create temp file for download + tmpFile, err := os.CreateTemp("", "taracode-upgrade-*") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + // Download the binary + resp, err := i.httpClient.Get(downloadURL) + if err != nil { + tmpFile.Close() + return fmt.Errorf("failed to download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + tmpFile.Close() + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + // Show download progress + counter := &writeCounter{Total: resp.ContentLength} + _, err = io.Copy(tmpFile, io.TeeReader(resp.Body, counter)) + tmpFile.Close() + if err != nil { + return fmt.Errorf("failed to save download: %w", err) + } + fmt.Println() // New line after progress + + // Make executable + if err := os.Chmod(tmpPath, 0755); err != nil { + return fmt.Errorf("failed to make executable: %w", err) + } + + // Verify the downloaded binary works + cmd := exec.Command(tmpPath, "--version") + if output, err := cmd.Output(); err != nil { + return fmt.Errorf("downloaded binary verification failed: %w", err) + } else { + fmt.Printf("Downloaded: %s", output) + } + + // Replace the current binary + // First, try to backup the old binary + backupPath := execPath + ".backup" + os.Remove(backupPath) // Remove old backup if exists + if err := os.Rename(execPath, backupPath); err != nil { + // If rename fails, try with sudo + return i.replaceWithSudo(tmpPath, execPath) + } + + // Move new binary to target location + if err := os.Rename(tmpPath, execPath); err != nil { + // Restore backup + os.Rename(backupPath, execPath) + return fmt.Errorf("failed to install new binary: %w", err) + } + + // Remove backup on success + os.Remove(backupPath) + + return nil +} + +// replaceWithSudo attempts to replace the binary using sudo +func (i *Installer) replaceWithSudo(srcPath, dstPath string) error { + fmt.Println("Need elevated permissions to install...") + + cmd := exec.Command("sudo", "mv", srcPath, dstPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if err := cmd.Run(); err != nil { + return fmt.Errorf("sudo mv failed: %w", err) + } + + return nil +} + +// writeCounter counts bytes written and shows progress +type writeCounter struct { + Total int64 + Downloaded int64 +} + +func (wc *writeCounter) Write(p []byte) (int, error) { + n := len(p) + wc.Downloaded += int64(n) + + // Print progress + if wc.Total > 0 { + percent := float64(wc.Downloaded) / float64(wc.Total) * 100 + fmt.Printf("\rDownloading... %.1f%% (%d/%d bytes)", percent, wc.Downloaded, wc.Total) + } else { + fmt.Printf("\rDownloading... %d bytes", wc.Downloaded) + } + + return n, nil +} + +// GetUpgradeCommand returns the appropriate upgrade command for the user +func GetUpgradeCommand(method InstallMethod) string { + switch method { + case InstallMethodHomebrew: + return "brew upgrade taracode" + case InstallMethodGo: + return "go install github.com/tara-vision/taracode@latest" + case InstallMethodCurl: + return "curl -fsSL https://code.tara.vision/install.sh | bash" + default: + return "curl -fsSL https://code.tara.vision/install.sh | bash" + } +} diff --git a/internal/upgrade/types.go b/internal/upgrade/types.go new file mode 100644 index 0000000..9b671a3 --- /dev/null +++ b/internal/upgrade/types.go @@ -0,0 +1,69 @@ +package upgrade + +import "time" + +// ReleaseInfo contains information about a GitHub release +type ReleaseInfo struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt time.Time `json:"published_at"` + HTMLURL string `json:"html_url"` + Assets []Asset `json:"assets"` +} + +// Asset represents a release asset (binary file) +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Size int64 `json:"size"` +} + +// UpgradeState stores persistent upgrade state +type UpgradeState struct { + LastCheckTime time.Time `json:"last_check_time"` + LastCheckVersion string `json:"last_check_version"` + SkippedVersion string `json:"skipped_version,omitempty"` + InstallMethod string `json:"install_method,omitempty"` // curl, homebrew, go, manual +} + +// CheckResult contains the result of a version check +type CheckResult struct { + CurrentVersion string + LatestVersion string + UpdateAvailable bool + ReleaseInfo *ReleaseInfo + Changelog string + DownloadURL string + InstallMethod string + SkippedByUser bool +} + +// InstallMethod represents how taracode was installed +type InstallMethod string + +const ( + InstallMethodCurl InstallMethod = "curl" + InstallMethodHomebrew InstallMethod = "homebrew" + InstallMethodGo InstallMethod = "go" + InstallMethodManual InstallMethod = "manual" + InstallMethodUnknown InstallMethod = "unknown" +) + +// UpgradeConfig holds upgrade-related configuration +type UpgradeConfig struct { + AutoCheck bool `mapstructure:"auto_check"` + CheckInterval time.Duration `mapstructure:"check_interval"` + AutoUpgrade bool `mapstructure:"auto_upgrade"` + ShowChangelog bool `mapstructure:"show_changelog"` +} + +// DefaultConfig returns the default upgrade configuration +func DefaultConfig() UpgradeConfig { + return UpgradeConfig{ + AutoCheck: true, + CheckInterval: 24 * time.Hour, + AutoUpgrade: false, // Require user confirmation by default + ShowChangelog: true, + } +}