diff --git a/cmd/ckb/update.go b/cmd/ckb/update.go new file mode 100644 index 00000000..d057c130 --- /dev/null +++ b/cmd/ckb/update.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "runtime" + + "github.com/SimplyLiz/CodeMCP/internal/update" + "github.com/SimplyLiz/CodeMCP/internal/version" + + "github.com/spf13/cobra" +) + +var ( + updateDryRun bool +) + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Update CKB to the latest version", + Long: `Update CKB to the latest version using the appropriate package manager. + +Automatically detects how CKB was installed and runs the correct update command: + - npm: npm update -g @tastehub/ckb + - brew: brew upgrade ckb + - go: go install github.com/SimplyLiz/CodeMCP/cmd/ckb@latest + +If the installation method cannot be detected, opens the GitHub releases page.`, + Run: runUpdate, +} + +func init() { + rootCmd.AddCommand(updateCmd) + updateCmd.Flags().BoolVar(&updateDryRun, "dry-run", false, "Show the update command without executing it") +} + +func runUpdate(cmd *cobra.Command, args []string) { + checker := update.NewChecker() + method := checker.InstallMethod() + + fmt.Printf("Current version: %s\n", version.Version) + fmt.Printf("Install method: %s\n", formatInstallMethod(method)) + + switch method { + case update.InstallMethodNPM: + runPackageManagerUpdate("npm", []string{"update", "-g", "@tastehub/ckb"}) + case update.InstallMethodBrew: + runPackageManagerUpdate("brew", []string{"upgrade", "ckb"}) + case update.InstallMethodGo: + runPackageManagerUpdate("go", []string{"install", "github.com/SimplyLiz/CodeMCP/cmd/ckb@latest"}) + default: + openReleasesPage() + } +} + +func formatInstallMethod(method update.InstallMethod) string { + switch method { + case update.InstallMethodNPM: + return "npm" + case update.InstallMethodBrew: + return "Homebrew" + case update.InstallMethodGo: + return "go install" + default: + return "unknown" + } +} + +func runPackageManagerUpdate(command string, args []string) { + cmdStr := command + for _, arg := range args { + cmdStr += " " + arg + } + + if updateDryRun { + fmt.Printf("\nWould run: %s\n", cmdStr) + return + } + + fmt.Printf("\nRunning: %s\n\n", cmdStr) + + execCmd := exec.Command(command, args...) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + execCmd.Stdin = os.Stdin + + if err := execCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "\nUpdate failed: %v\n", err) + os.Exit(1) + } + + fmt.Println("\nUpdate complete!") +} + +func openReleasesPage() { + url := "https://github.com/SimplyLiz/CodeMCP/releases" + + fmt.Printf("\nCould not detect installation method.\n") + fmt.Printf("Please visit: %s\n", url) + + if updateDryRun { + fmt.Printf("\nWould open: %s\n", url) + return + } + + // Try to open the URL in the default browser + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return + } + + _ = cmd.Start() +} diff --git a/codecov.yml b/codecov.yml index 84422959..62279212 100644 --- a/codecov.yml +++ b/codecov.yml @@ -17,6 +17,7 @@ coverage: default: target: 30% threshold: 0% + only_pulls: true comment: # Codecov's recommended condensed format @@ -42,3 +43,4 @@ flag_management: threshold: 1% - type: patch target: 30% + only_pulls: true diff --git a/internal/update/check.go b/internal/update/check.go index d0e7859f..aca53fd7 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -1,5 +1,6 @@ -// Package update provides npm update checking for CKB. -// It checks if a newer version is available on npm and notifies the user. +// Package update provides update checking for CKB. +// It checks if a newer version is available and notifies the user with +// the appropriate update command based on their installation method. package update import ( @@ -9,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "time" @@ -30,6 +32,26 @@ const ( // npmPackageName is the npm package name npmPackageName = "@tastehub/ckb" + + // goModulePath is the Go module path for go install + goModulePath = "github.com/SimplyLiz/CodeMCP/cmd/ckb" + + // brewPackageName is the Homebrew formula name + brewPackageName = "ckb" +) + +// InstallMethod represents how CKB was installed +type InstallMethod string + +const ( + // InstallMethodNPM indicates installation via npm + InstallMethodNPM InstallMethod = "npm" + // InstallMethodBrew indicates installation via Homebrew + InstallMethodBrew InstallMethod = "brew" + // InstallMethodGo indicates installation via go install + InstallMethodGo InstallMethod = "go" + // InstallMethodUnknown indicates unknown installation method + InstallMethodUnknown InstallMethod = "unknown" ) // githubReleaseInfo represents the relevant fields from GitHub Releases API @@ -46,24 +68,24 @@ type UpdateInfo struct { // Checker handles update checking with caching type Checker struct { - cache *Cache - isNpmPath bool + cache *Cache + installMethod InstallMethod } // NewChecker creates a new update checker. -// It automatically detects if running from an npm installation. +// It automatically detects the installation method (npm, brew, go, or unknown). func NewChecker() *Checker { return &Checker{ - cache: NewCache(), - isNpmPath: detectNpmInstall(), + cache: NewCache(), + installMethod: detectInstallMethod(), } } -// detectNpmInstall checks if the current executable is running from an npm installation -func detectNpmInstall() bool { +// detectInstallMethod detects how CKB was installed based on the executable path +func detectInstallMethod() InstallMethod { execPath, err := os.Executable() if err != nil { - return false + return InstallMethodUnknown } // Resolve symlinks to get the real path @@ -72,15 +94,101 @@ func detectNpmInstall() bool { realPath = execPath } + // Check each installation method + if detectNpmInstall(realPath) { + return InstallMethodNPM + } + if detectBrewInstall(realPath) { + return InstallMethodBrew + } + if detectGoInstall(realPath) { + return InstallMethodGo + } + + return InstallMethodUnknown +} + +// detectNpmInstall checks if the path indicates an npm installation +func detectNpmInstall(realPath string) bool { // Check if path contains node_modules/@tastehub/ckb return strings.Contains(realPath, "node_modules") && strings.Contains(realPath, "@tastehub") && strings.Contains(realPath, "ckb") } +// detectBrewInstall checks if the path indicates a Homebrew installation +func detectBrewInstall(realPath string) bool { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + return false + } + + // macOS Apple Silicon: /opt/homebrew/Cellar/ckb/... + // macOS Intel / Linux: /usr/local/Cellar/ckb/... + // Also check for /home/linuxbrew/.linuxbrew/Cellar/... + brewPrefixes := []string{ + "/opt/homebrew/Cellar/", + "/usr/local/Cellar/", + "/home/linuxbrew/.linuxbrew/Cellar/", + } + + for _, prefix := range brewPrefixes { + if strings.HasPrefix(realPath, prefix) { + return true + } + } + + // Also check HOMEBREW_PREFIX environment variable + if homebrewPrefix := os.Getenv("HOMEBREW_PREFIX"); homebrewPrefix != "" { + cellarPath := filepath.Join(homebrewPrefix, "Cellar") + if strings.HasPrefix(realPath, cellarPath) { + return true + } + } + + return false +} + +// detectGoInstall checks if the path indicates a go install installation +func detectGoInstall(realPath string) bool { + // Check for common Go binary locations + // $GOPATH/bin (defaults to ~/go/bin) + // $GOBIN if set + + // Check GOBIN first + if goBin := os.Getenv("GOBIN"); goBin != "" { + if strings.HasPrefix(realPath, goBin) { + return true + } + } + + // Check GOPATH/bin + goPath := os.Getenv("GOPATH") + if goPath == "" { + // Default GOPATH is ~/go + if home, err := os.UserHomeDir(); err == nil { + goPath = filepath.Join(home, "go") + } + } + + if goPath != "" { + goBinPath := filepath.Join(goPath, "bin") + if strings.HasPrefix(realPath, goBinPath) { + return true + } + } + + // Also check for /go/bin pattern anywhere in path (covers non-standard setups) + return strings.Contains(realPath, string(filepath.Separator)+"go"+string(filepath.Separator)+"bin"+string(filepath.Separator)) +} + +// InstallMethod returns the detected installation method +func (c *Checker) InstallMethod() InstallMethod { + return c.installMethod +} + // IsNpmInstall returns true if running from an npm installation func (c *Checker) IsNpmInstall() bool { - return c.isNpmPath + return c.installMethod == InstallMethodNPM } // CheckCached checks the cache for a pending update notification. @@ -222,10 +330,16 @@ func (c *Checker) compareVersions(latest string) *UpdateInfo { // getUpgradeCommand returns the appropriate upgrade command based on install method func (c *Checker) getUpgradeCommand() string { - if c.isNpmPath { + switch c.installMethod { + case InstallMethodNPM: return "npm update -g " + npmPackageName + case InstallMethodBrew: + return "brew upgrade " + brewPackageName + case InstallMethodGo: + return "go install " + goModulePath + "@latest" + default: + return githubReleasesPage } - return githubReleasesPage } // isNewerVersion returns true if version a is newer than version b. diff --git a/internal/update/check_test.go b/internal/update/check_test.go index c89c622a..dfbe0cf0 100644 --- a/internal/update/check_test.go +++ b/internal/update/check_test.go @@ -61,8 +61,8 @@ func TestChecker_Check_DisabledByEnv(t *testing.T) { defer func() { _ = os.Unsetenv("CKB_NO_UPDATE_CHECK") }() checker := &Checker{ - cache: NewCache(), - isNpmPath: true, // Pretend we're an npm install + cache: NewCache(), + installMethod: InstallMethodNPM, // Pretend we're an npm install } result := checker.Check(context.Background()) @@ -83,7 +83,7 @@ func TestChecker_CheckCached_EmptyCache(t *testing.T) { cache: &Cache{ path: filepath.Join(tmpDir, "update-check.json"), }, - isNpmPath: false, + installMethod: InstallMethodUnknown, } result := checker.CheckCached() @@ -107,8 +107,8 @@ func TestChecker_CheckCached_WithCachedUpdate(t *testing.T) { cache.Set("99.0.0") checker := &Checker{ - cache: cache, - isNpmPath: false, + cache: cache, + installMethod: InstallMethodUnknown, } result := checker.CheckCached() @@ -138,8 +138,8 @@ func TestChecker_CheckCached_NpmInstall(t *testing.T) { cache.Set("99.0.0") checker := &Checker{ - cache: cache, - isNpmPath: true, // npm install + cache: cache, + installMethod: InstallMethodNPM, // npm install } result := checker.CheckCached() @@ -154,8 +154,8 @@ func TestChecker_CheckCached_NpmInstall(t *testing.T) { func TestChecker_FetchLatestVersion_Timeout(t *testing.T) { checker := &Checker{ - cache: NewCache(), - isNpmPath: false, + cache: NewCache(), + installMethod: InstallMethodUnknown, } // Test with a very short timeout context @@ -281,4 +281,128 @@ func TestUpdateInfo_FormatUpdateMessage(t *testing.T) { t.Error("expected non-empty plain message") } }) + + t.Run("brew command", func(t *testing.T) { + info := &UpdateInfo{ + CurrentVersion: "7.3.0", + LatestVersion: "7.4.0", + UpdateCommand: "brew upgrade ckb", + } + + msg := info.FormatUpdateMessage() + if !strings.Contains(msg, "Run:") { + t.Error("expected 'Run:' prefix for brew command") + } + if !strings.Contains(msg, "brew upgrade") { + t.Error("expected brew upgrade command in message") + } + }) + + t.Run("go install command", func(t *testing.T) { + info := &UpdateInfo{ + CurrentVersion: "7.3.0", + LatestVersion: "7.4.0", + UpdateCommand: "go install github.com/SimplyLiz/CodeMCP/cmd/ckb@latest", + } + + msg := info.FormatUpdateMessage() + if !strings.Contains(msg, "Run:") { + t.Error("expected 'Run:' prefix for go install command") + } + if !strings.Contains(msg, "go install") { + t.Error("expected go install command in message") + } + }) +} + +func TestDetectNpmInstall(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"/usr/local/lib/node_modules/@tastehub/ckb-darwin-arm64/bin/ckb", true}, + {"/home/user/.npm-global/lib/node_modules/@tastehub/ckb/bin/ckb", true}, + {"/opt/homebrew/Cellar/ckb/8.0.0/bin/ckb", false}, + {"/home/user/go/bin/ckb", false}, + {"/usr/local/bin/ckb", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := detectNpmInstall(tt.path) + if result != tt.expected { + t.Errorf("detectNpmInstall(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestDetectBrewInstall(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"/opt/homebrew/Cellar/ckb/8.0.0/bin/ckb", true}, + {"/usr/local/Cellar/ckb/8.0.0/bin/ckb", true}, + {"/home/linuxbrew/.linuxbrew/Cellar/ckb/8.0.0/bin/ckb", true}, + {"/usr/local/lib/node_modules/@tastehub/ckb/bin/ckb", false}, + {"/home/user/go/bin/ckb", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := detectBrewInstall(tt.path) + if result != tt.expected { + t.Errorf("detectBrewInstall(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestDetectGoInstall(t *testing.T) { + home, _ := os.UserHomeDir() + + tests := []struct { + path string + expected bool + }{ + {filepath.Join(home, "go", "bin", "ckb"), true}, + {"/home/user/go/bin/ckb", true}, + {"/opt/homebrew/Cellar/ckb/8.0.0/bin/ckb", false}, + {"/usr/local/lib/node_modules/@tastehub/ckb/bin/ckb", false}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := detectGoInstall(tt.path) + if result != tt.expected { + t.Errorf("detectGoInstall(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestChecker_GetUpgradeCommand(t *testing.T) { + tests := []struct { + method InstallMethod + expected string + }{ + {InstallMethodNPM, "npm update -g @tastehub/ckb"}, + {InstallMethodBrew, "brew upgrade ckb"}, + {InstallMethodGo, "go install github.com/SimplyLiz/CodeMCP/cmd/ckb@latest"}, + {InstallMethodUnknown, githubReleasesPage}, + } + + for _, tt := range tests { + t.Run(string(tt.method), func(t *testing.T) { + checker := &Checker{ + cache: NewCache(), + installMethod: tt.method, + } + result := checker.getUpgradeCommand() + if result != tt.expected { + t.Errorf("getUpgradeCommand() for %s = %q, want %q", tt.method, result, tt.expected) + } + }) + } }