Detailed changes
@@ -10,6 +10,7 @@ require (
charm.land/x/vcr v0.1.1
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/MakeNowJust/heredoc v1.0.0
+ github.com/Masterminds/semver/v3 v3.4.0
github.com/PuerkitoBio/goquery v1.11.0
github.com/alecthomas/chroma/v2 v2.20.0
github.com/atotto/clipboard v0.1.4
@@ -28,6 +28,8 @@ github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
@@ -23,6 +23,7 @@ import (
"github.com/charmbracelet/crush/internal/stringext"
termutil "github.com/charmbracelet/crush/internal/term"
"github.com/charmbracelet/crush/internal/tui"
+ tuiutil "github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/crush/internal/update"
"github.com/charmbracelet/crush/internal/version"
"github.com/charmbracelet/fang"
@@ -100,6 +101,9 @@ crush -y
tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
go app.Subscribe(program)
+ // Start async update check unless disabled.
+ go checkForUpdateAsync(cmd.Context(), program)
+
if _, err := program.Run(); err != nil {
event.Error(err)
slog.Error("TUI run error", "error", err)
@@ -176,9 +180,23 @@ func hasVersionFlag() bool {
return false
}
+// isAutoUpdateDisabled checks if update checks are disabled via env var.
+// Config is not loaded at this point (called before Execute), so only env var is checked.
+func isAutoUpdateDisabled() bool {
+ if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
+ v, _ := strconv.ParseBool(str)
+ return v
+ }
+ return false
+}
+
// checkForUpdateSync performs a synchronous update check with a short timeout.
// Returns a formatted update message if an update is available, empty string otherwise.
func checkForUpdateSync() string {
+ if isAutoUpdateDisabled() {
+ return ""
+ }
+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
@@ -194,6 +212,75 @@ func checkForUpdateSync() string {
return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
}
+// checkForUpdateAsync checks for updates in the background and applies them if possible.
+func checkForUpdateAsync(ctx context.Context, program *tea.Program) {
+ // Check config (if loaded) or env var.
+ if isAutoUpdateDisabled() {
+ return
+ }
+ if cfg := config.Get(); cfg != nil && cfg.Options.DisableAutoUpdate {
+ return
+ }
+
+ checkCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+
+ info, err := update.Check(checkCtx, version.Version, update.Default)
+ if err != nil || !info.Available() || info.IsDevelopment() {
+ return
+ }
+
+ // Check install method.
+ method := update.DetectInstallMethod()
+ if !method.CanSelfUpdate() {
+ // Package manager install - show instructions.
+ program.Send(tuiutil.InfoMsg{
+ Type: tuiutil.InfoTypeUpdate,
+ Msg: fmt.Sprintf("Update available: v%s → v%s. Run: %s", info.Current, info.Latest, method.UpdateInstructions()),
+ TTL: 30 * time.Second,
+ })
+ return
+ }
+
+ // Attempt self-update.
+ asset, err := update.FindAsset(info.Release.Assets)
+ if err != nil {
+ program.Send(tuiutil.InfoMsg{
+ Type: tuiutil.InfoTypeWarn,
+ Msg: "Update available but failed to find asset. Run 'crush update' for details.",
+ TTL: 15 * time.Second,
+ })
+ return
+ }
+
+ binaryPath, err := update.Download(checkCtx, asset, info.Release)
+ if err != nil {
+ program.Send(tuiutil.InfoMsg{
+ Type: tuiutil.InfoTypeWarn,
+ Msg: "Update download failed. Run 'crush update' for details.",
+ TTL: 15 * time.Second,
+ })
+ return
+ }
+ defer os.Remove(binaryPath)
+
+ if err := update.Apply(binaryPath); err != nil {
+ program.Send(tuiutil.InfoMsg{
+ Type: tuiutil.InfoTypeWarn,
+ Msg: "Update failed to install. Run 'crush update' for details.",
+ TTL: 15 * time.Second,
+ })
+ return
+ }
+
+ // Success!
+ program.Send(tuiutil.InfoMsg{
+ Type: tuiutil.InfoTypeUpdate,
+ Msg: fmt.Sprintf("Updated to v%s! Restart Crush to use the new version.", info.Latest),
+ TTL: 30 * time.Second,
+ })
+}
+
func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
if termutil.SupportsProgressBar() {
_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
@@ -27,6 +27,9 @@ crush update
# Apply the update if available
crush update apply
+
+# Force re-download even if already on latest version
+crush update apply --force
`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
@@ -45,7 +48,9 @@ crush update apply
if info.IsDevelopment() {
fmt.Fprintf(os.Stderr, "You are running a development version of Crush (%s).\n", info.Current)
fmt.Fprintf(os.Stderr, "The latest stable release is v%s.\n", info.Latest)
- fmt.Fprintf(os.Stderr, "Visit %s to learn more.\n", info.URL)
+ fmt.Fprintf(os.Stderr, "To install the latest stable version, run:\n")
+ fmt.Fprintf(os.Stderr, " go install github.com/charmbracelet/crush@latest\n")
+ fmt.Fprintf(os.Stderr, "Or visit %s to download manually.\n", info.URL)
return nil
}
@@ -54,6 +59,15 @@ crush update apply
return nil
}
+ // Check install method and provide appropriate instructions.
+ method := update.DetectInstallMethod()
+ if !method.CanSelfUpdate() {
+ fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
+ fmt.Fprintf(os.Stderr, "Crush was installed via %s. To update, run:\n", method)
+ fmt.Fprintf(os.Stderr, " %s\n", method.UpdateInstructions())
+ return nil
+ }
+
fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
fmt.Fprintf(os.Stderr, "Run 'crush update apply' to install the latest version.\n")
fmt.Fprintf(os.Stderr, "Or visit %s to download manually.\n", info.URL)
@@ -69,11 +83,26 @@ var updateApplyCmd = &cobra.Command{
Example: `
# Apply the latest update
crush update apply
+
+# Force re-download even if already on latest version
+crush update apply --force
`,
RunE: func(cmd *cobra.Command, args []string) error {
+ force, _ := cmd.Flags().GetBool("force")
+
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
defer cancel()
+ // Check install method first.
+ method := update.DetectInstallMethod()
+ if !method.CanSelfUpdate() {
+ fmt.Fprintf(os.Stderr, "Crush was installed via %s.\n", method)
+ fmt.Fprintf(os.Stderr, "Self-update is not supported for this installation method.\n")
+ fmt.Fprintf(os.Stderr, "To update, run:\n")
+ fmt.Fprintf(os.Stderr, " %s\n", method.UpdateInstructions())
+ return nil
+ }
+
spinner := newUpdateSpinner(ctx, cancel, "Checking for updates")
spinner.Start()
@@ -85,24 +114,22 @@ crush update apply
if info.IsDevelopment() {
spinner.Stop()
- return fmt.Errorf("cannot update development versions automatically")
+ fmt.Fprintf(os.Stderr, "You are running a development version of Crush (%s).\n", info.Current)
+ fmt.Fprintf(os.Stderr, "Self-update is not supported for development versions.\n")
+ fmt.Fprintf(os.Stderr, "To install the latest stable version, run:\n")
+ fmt.Fprintf(os.Stderr, " go install github.com/charmbracelet/crush@latest\n")
+ return nil
}
- if !info.Available() {
+ if !info.Available() && !force {
spinner.Stop()
fmt.Fprintf(os.Stderr, "You are already running the latest version (v%s).\n", info.Current)
+ fmt.Fprintf(os.Stderr, "Use --force to re-download and reinstall.\n")
return nil
}
- // Get the latest release with assets.
- release, err := update.Default.Latest(ctx)
- if err != nil {
- spinner.Stop()
- return fmt.Errorf("failed to fetch release information: %w", err)
- }
-
// Find the appropriate asset for this platform.
- asset, err := update.FindAsset(release.Assets)
+ asset, err := update.FindAsset(info.Release.Assets)
if err != nil {
spinner.Stop()
return fmt.Errorf("failed to find update for your platform: %w", err)
@@ -113,7 +140,7 @@ crush update apply
spinner.Start()
// Download the asset.
- binaryPath, err := update.Download(ctx, asset, release)
+ binaryPath, err := update.Download(ctx, asset, info.Release)
if err != nil {
spinner.Stop()
return fmt.Errorf("failed to download update: %w", err)
@@ -132,7 +159,11 @@ crush update apply
spinner.Stop()
- fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
+ if force && !info.Available() {
+ fmt.Fprintf(os.Stderr, "Successfully reinstalled v%s!\n", info.Latest)
+ } else {
+ fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
+ }
fmt.Fprintf(os.Stderr, "Run 'crush -v' to verify the new version.\n")
return nil
@@ -161,5 +192,6 @@ func newUpdateSpinner(ctx context.Context, cancel context.CancelFunc, label stri
}
func init() {
+ updateApplyCmd.Flags().BoolP("force", "f", false, "Force re-download even if already on latest version")
updateCmd.AddCommand(updateApplyCmd)
}
@@ -221,6 +221,7 @@ type Options struct {
DataDirectory string `json:"data_directory,omitempty" jsonschema:"description=Directory for storing application data (relative to working directory),default=.crush,example=.crush"` // Relative to the cwd
DisabledTools []string `json:"disabled_tools" jsonschema:"description=Tools to disable"`
DisableProviderAutoUpdate bool `json:"disable_provider_auto_update,omitempty" jsonschema:"description=Disable providers auto-update,default=false"`
+ DisableAutoUpdate bool `json:"disable_auto_update,omitempty" jsonschema:"description=Disable automatic update checks,default=false"`
Attribution *Attribution `json:"attribution,omitempty" jsonschema:"description=Attribution settings for generated content"`
DisableMetrics bool `json:"disable_metrics,omitempty" jsonschema:"description=Disable sending metrics,default=false"`
InitializeAs string `json:"initialize_as,omitempty" jsonschema:"description=Name of the context file to create/update during project initialization,default=AGENTS.md,example=AGENTS.md,example=CRUSH.md,example=CLAUDE.md,example=docs/LLMs.md"`
@@ -379,6 +379,10 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
c.Options.DisableProviderAutoUpdate, _ = strconv.ParseBool(str)
}
+ if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
+ c.Options.DisableAutoUpdate, _ = strconv.ParseBool(str)
+ }
+
if c.Options.Attribution == nil {
c.Options.Attribution = &Attribution{
TrailerStyle: TrailerStyleAssistedBy,
@@ -19,11 +19,12 @@ import (
"strings"
"time"
+ "github.com/Masterminds/semver/v3"
"github.com/charmbracelet/crush/internal/version"
)
const (
- githubApiUrl = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
+ githubAPIURL = "https://api.github.com/repos/charmbracelet/crush/releases/latest"
maxBinarySize = 500 * 1024 * 1024 // 500MB max for extracted binary
maxArchiveSize = 500 * 1024 * 1024 // 500MB max for downloaded archive
maxChecksumsSize = 1 * 1024 * 1024 // 1MB max for checksums.txt
@@ -42,34 +43,139 @@ type Info struct {
Current string
Latest string
URL string
+ Release *Release
}
-// Matches a version string like:
-// v0.0.0-0.20251231235959-06c807842604
-var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+\.\d{14}-[0-9a-f]{12}$`)
+// goInstallRegexp matches pseudo-versions from go install:
+// v0.0.0-20251231235959-06c807842604
+var goInstallRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d{14}-[0-9a-f]{12}$`)
+// gitDescribeRegexp matches git describe versions:
+// v0.19.0-15-g1a2b3c4d (tag-commits-ghash)
+var gitDescribeRegexp = regexp.MustCompile(`^v?\d+\.\d+\.\d+-\d+-g[0-9a-f]+$`)
+
+// IsDevelopment returns true if the current version appears to be a
+// development build rather than an official release.
func (i Info) IsDevelopment() bool {
- return i.Current == "devel" || i.Current == "unknown" || strings.Contains(i.Current, "dirty") || goInstallRegexp.MatchString(i.Current)
+ return i.Current == "devel" ||
+ i.Current == "unknown" ||
+ strings.Contains(i.Current, "dirty") ||
+ goInstallRegexp.MatchString(i.Current) ||
+ gitDescribeRegexp.MatchString(i.Current)
}
// Available returns true if there's an update available.
-//
-// If both current and latest are stable versions, returns true if versions are
-// different.
-// If current is a pre-release and latest isn't, returns true.
-// If latest is a pre-release and current isn't, returns false.
+// Uses proper semver comparison to handle version ordering correctly.
+// Returns false if either version cannot be parsed.
+// Special case: if current is stable and latest is a prerelease, returns false
+// (we don't offer prerelease updates to stable users).
func (i Info) Available() bool {
- cpr := strings.Contains(i.Current, "-")
- lpr := strings.Contains(i.Latest, "-")
- // current is pre release && latest isn't a prerelease
- if cpr && !lpr {
- return true
- }
- // latest is pre release && current isn't a prerelease
- if lpr && !cpr {
+ current, err := semver.NewVersion(i.Current)
+ if err != nil {
+ return false
+ }
+ latest, err := semver.NewVersion(i.Latest)
+ if err != nil {
+ return false
+ }
+
+ // Don't offer prerelease updates to stable users.
+ if current.Prerelease() == "" && latest.Prerelease() != "" {
return false
}
- return i.Current != i.Latest
+
+ return latest.GreaterThan(current)
+}
+
+// InstallMethod represents how Crush was installed.
+type InstallMethod int
+
+const (
+ InstallMethodUnknown InstallMethod = iota
+ InstallMethodBinary // Direct binary download
+ InstallMethodHomebrew
+ InstallMethodNPM
+ InstallMethodAUR
+ InstallMethodNix
+ InstallMethodWinget
+ InstallMethodScoop
+ InstallMethodApt
+ InstallMethodYum
+ InstallMethodGoInstall
+)
+
+// String returns a human-readable name for the install method.
+func (m InstallMethod) String() string {
+ switch m {
+ case InstallMethodBinary:
+ return "binary"
+ case InstallMethodHomebrew:
+ return "Homebrew"
+ case InstallMethodNPM:
+ return "npm"
+ case InstallMethodAUR:
+ return "AUR"
+ case InstallMethodNix:
+ return "Nix"
+ case InstallMethodWinget:
+ return "winget"
+ case InstallMethodScoop:
+ return "Scoop"
+ case InstallMethodApt:
+ return "apt"
+ case InstallMethodYum:
+ return "yum"
+ case InstallMethodGoInstall:
+ return "go install"
+ default:
+ return "unknown"
+ }
+}
+
+// CanSelfUpdate returns true if this install method supports self-updating.
+func (m InstallMethod) CanSelfUpdate() bool {
+ return m == InstallMethodBinary || m == InstallMethodUnknown
+}
+
+// UpdateInstructions returns the command to update Crush for this install method.
+func (m InstallMethod) UpdateInstructions() string {
+ switch m {
+ case InstallMethodHomebrew:
+ return "brew upgrade charmbracelet/tap/crush"
+ case InstallMethodNPM:
+ return "npm update -g @charmland/crush"
+ case InstallMethodAUR:
+ return "yay -Syu crush-bin"
+ case InstallMethodNix:
+ return "nix flake update # or update your NUR channel"
+ case InstallMethodWinget:
+ return "winget upgrade charmbracelet.crush"
+ case InstallMethodScoop:
+ return "scoop update crush"
+ case InstallMethodApt:
+ return "sudo apt update && sudo apt upgrade crush"
+ case InstallMethodYum:
+ return "sudo yum update crush"
+ case InstallMethodGoInstall:
+ return "go install github.com/charmbracelet/crush@latest"
+ default:
+ return ""
+ }
+}
+
+// DetectInstallMethod attempts to determine how Crush was installed.
+// This is implemented per-platform in update_darwin.go, update_unix.go,
+// and update_windows.go.
+func DetectInstallMethod() InstallMethod {
+ exe, err := os.Executable()
+ if err != nil {
+ return InstallMethodUnknown
+ }
+ exe, err = filepath.EvalSymlinks(exe)
+ if err != nil {
+ return InstallMethodUnknown
+ }
+ return detectInstallMethod(exe)
}
// Check checks if a new version is available.
@@ -87,6 +193,7 @@ func Check(ctx context.Context, current string, client Client) (Info, error) {
info.Latest = strings.TrimPrefix(release.TagName, "v")
info.Current = strings.TrimPrefix(info.Current, "v")
info.URL = release.HTMLURL
+ info.Release = release
return info, nil
}
@@ -94,6 +201,7 @@ func Check(ctx context.Context, current string, client Client) (Info, error) {
type Asset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
+ Size int64 `json:"size"`
}
// Release represents a GitHub release.
@@ -116,7 +224,7 @@ func (c *github) Latest(ctx context.Context) (*Release, error) {
Timeout: 30 * time.Second,
}
- req, err := http.NewRequestWithContext(ctx, "GET", githubApiUrl, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
if err != nil {
return nil, err
}
@@ -5,6 +5,9 @@ package update
import (
"debug/macho"
"fmt"
+ "os"
+ "path/filepath"
+ "strings"
)
const binaryName = "crush"
@@ -21,3 +24,38 @@ func validateBinary(path string) error {
}
return nil
}
+
+// detectInstallMethod determines how Crush was installed on macOS.
+func detectInstallMethod(exePath string) InstallMethod {
+ // Check for Homebrew installation.
+ // Apple Silicon: /opt/homebrew/Cellar/crush/...
+ // Intel: /usr/local/Cellar/crush/...
+ if strings.Contains(exePath, "/Cellar/") ||
+ strings.HasPrefix(exePath, "/opt/homebrew/") ||
+ strings.HasPrefix(exePath, "/usr/local/Homebrew/") {
+ return InstallMethodHomebrew
+ }
+
+ // Check for Nix installation.
+ if strings.HasPrefix(exePath, "/nix/store/") {
+ return InstallMethodNix
+ }
+
+ // Check for npm global installation.
+ // Typically in /usr/local/lib/node_modules or ~/.npm-global
+ if strings.Contains(exePath, "node_modules") {
+ return InstallMethodNPM
+ }
+
+ // Check for go install (typically in ~/go/bin or $GOPATH/bin).
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" {
+ home, _ := os.UserHomeDir()
+ gopath = filepath.Join(home, "go")
+ }
+ if strings.HasPrefix(exePath, filepath.Join(gopath, "bin")) {
+ return InstallMethodGoInstall
+ }
+
+ return InstallMethodUnknown
+}
@@ -5,6 +5,7 @@ import (
"archive/zip"
"compress/gzip"
"context"
+ "errors"
"os"
"path/filepath"
"runtime"
@@ -44,6 +45,13 @@ func TestCheckForUpdate_Beta(t *testing.T) {
})
}
+func TestCheckForUpdate_NetworkError(t *testing.T) {
+ t.Parallel()
+ _, err := Check(t.Context(), "0.19.0", errorClient{errors.New("network unreachable")})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "failed to fetch")
+}
+
type testClient struct{ tag string }
// Latest implements Client.
@@ -54,6 +62,13 @@ func (t testClient) Latest(ctx context.Context) (*Release, error) {
}, nil
}
+type errorClient struct{ err error }
+
+// Latest implements Client.
+func (e errorClient) Latest(ctx context.Context) (*Release, error) {
+ return nil, e.err
+}
+
func TestFindAsset(t *testing.T) {
t.Parallel()
@@ -111,11 +126,16 @@ func TestIsDevelopment(t *testing.T) {
{"unknown version", "unknown", true},
{"dirty version", "0.19.0-dirty", true},
{"dirty with suffix", "0.19.0-10-g1234567-dirty", true},
- {"go install version", "v0.0.0-0.20251231235959-06c807842604", true},
+ {"go install pseudo-version", "v0.0.0-20251231235959-06c807842604", true},
+ {"git describe version", "v0.19.0-15-g1a2b3c4d", true},
+ {"git describe short hash", "0.19.0-3-gabcdef0", true},
+ {"git describe long hash", "v1.0.0-100-g0123456789ab", true},
{"stable version", "0.19.0", false},
{"pre-release beta", "0.19.0-beta.1", false},
{"pre-release rc", "0.19.0-rc.1", false},
{"pre-release alpha", "0.19.0-alpha.1", false},
+ {"major version", "1.0.0", false},
+ {"with v prefix", "v2.0.0", false},
}
for _, tt := range tests {
@@ -136,14 +156,46 @@ func TestAvailable(t *testing.T) {
latest string
want bool
}{
+ // Basic cases.
{"same version", "0.19.0", "0.19.0", false},
{"newer available", "0.19.0", "0.19.1", true},
- {"older latest (downgrade)", "0.19.1", "0.19.0", true},
+ {"older latest (no downgrade)", "0.19.1", "0.19.0", false},
+
+ // Pre-release handling.
{"rc to stable", "0.19.0-rc.1", "0.19.0", true},
{"stable to rc", "0.19.0", "0.20.0-rc.1", false},
{"alpha to beta", "0.19.0-alpha.1", "0.19.0-beta.1", true},
{"beta to rc", "0.19.0-beta.1", "0.19.0-rc.1", true},
{"same pre-release", "0.19.0-beta.1", "0.19.0-beta.1", false},
+
+ // Semver edge cases - multi-digit versions.
+ {"0.9.9 to 0.10.0", "0.9.9", "0.10.0", true},
+ {"0.19.0 to 0.19.10", "0.19.0", "0.19.10", true},
+ {"1.9.0 to 1.10.0", "1.9.0", "1.10.0", true},
+
+ // Major version bumps.
+ {"0.x to 1.0", "0.99.99", "1.0.0", true},
+ {"1.x to 2.0", "1.0.0", "2.0.0", true},
+
+ // With v prefix.
+ {"v prefix current", "v0.19.0", "0.19.1", true},
+ {"v prefix latest", "0.19.0", "v0.19.1", true},
+ {"v prefix both", "v0.19.0", "v0.19.1", true},
+
+ // Malformed versions should return false.
+ {"malformed current", "not-a-version", "0.19.0", false},
+ {"malformed latest", "0.19.0", "not-a-version", false},
+ {"both malformed", "bad", "worse", false},
+
+ // Build metadata is ignored in semver comparison.
+ {"build metadata only diff", "1.0.0+build.1", "1.0.0+build.2", false},
+ {"build metadata vs plain", "1.0.0", "1.0.0+build.1", false},
+ {"build metadata with newer version", "1.0.0+build.1", "1.0.1", true},
+
+ // Pre-release ordering edge cases.
+ {"prerelease alpha ordering", "1.0.0-alpha", "1.0.0-alpha.1", true},
+ {"prerelease numeric ordering", "1.0.0-1", "1.0.0-2", true},
+ {"prerelease alpha vs beta", "1.0.0-alpha.2", "1.0.0-beta.1", true},
}
for _, tt := range tests {
@@ -380,3 +432,188 @@ func TestApply(t *testing.T) {
require.Equal(t, content, dstContent)
})
}
+
+func TestInstallMethod_String(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ method InstallMethod
+ want string
+ }{
+ {InstallMethodUnknown, "unknown"},
+ {InstallMethodBinary, "binary"},
+ {InstallMethodHomebrew, "Homebrew"},
+ {InstallMethodNPM, "npm"},
+ {InstallMethodAUR, "AUR"},
+ {InstallMethodNix, "Nix"},
+ {InstallMethodWinget, "winget"},
+ {InstallMethodScoop, "Scoop"},
+ {InstallMethodApt, "apt"},
+ {InstallMethodYum, "yum"},
+ {InstallMethodGoInstall, "go install"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.want, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.want, tt.method.String())
+ })
+ }
+}
+
+func TestInstallMethod_CanSelfUpdate(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ method InstallMethod
+ want bool
+ }{
+ {InstallMethodUnknown, true},
+ {InstallMethodBinary, true},
+ {InstallMethodHomebrew, false},
+ {InstallMethodNPM, false},
+ {InstallMethodAUR, false},
+ {InstallMethodNix, false},
+ {InstallMethodWinget, false},
+ {InstallMethodScoop, false},
+ {InstallMethodApt, false},
+ {InstallMethodYum, false},
+ {InstallMethodGoInstall, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.method.String(), func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tt.want, tt.method.CanSelfUpdate())
+ })
+ }
+}
+
+func TestInstallMethod_UpdateInstructions(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ method InstallMethod
+ contains string
+ }{
+ {InstallMethodHomebrew, "brew upgrade"},
+ {InstallMethodNPM, "npm update"},
+ {InstallMethodAUR, "yay"},
+ {InstallMethodNix, "nix"},
+ {InstallMethodWinget, "winget upgrade"},
+ {InstallMethodScoop, "scoop update"},
+ {InstallMethodApt, "apt"},
+ {InstallMethodYum, "yum update"},
+ {InstallMethodGoInstall, "go install"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.method.String(), func(t *testing.T) {
+ t.Parallel()
+ instructions := tt.method.UpdateInstructions()
+ require.Contains(t, instructions, tt.contains)
+ })
+ }
+
+ t.Run("unknown returns empty", func(t *testing.T) {
+ t.Parallel()
+ require.Empty(t, InstallMethodUnknown.UpdateInstructions())
+ })
+
+ t.Run("binary returns empty", func(t *testing.T) {
+ t.Parallel()
+ require.Empty(t, InstallMethodBinary.UpdateInstructions())
+ })
+}
+
+func TestDetectInstallMethod_GoInstall(t *testing.T) {
+ // Cannot use t.Parallel() with t.Setenv.
+
+ // Test that a path in GOPATH/bin is detected as go install.
+ tmpDir := t.TempDir()
+ gopathBin := filepath.Join(tmpDir, "go", "bin")
+ require.NoError(t, os.MkdirAll(gopathBin, 0o755))
+
+ exePath := filepath.Join(gopathBin, "crush")
+ t.Setenv("GOPATH", filepath.Join(tmpDir, "go"))
+
+ method := detectInstallMethod(exePath)
+ require.Equal(t, InstallMethodGoInstall, method)
+}
+
+func TestDetectInstallMethod_Nix(t *testing.T) {
+ t.Parallel()
+
+ // Test that a path in /nix/store is detected as Nix.
+ exePath := "/nix/store/abc123-crush-0.21.0/bin/crush"
+ method := detectInstallMethod(exePath)
+ require.Equal(t, InstallMethodNix, method)
+}
+
+func TestDetectInstallMethod_Unknown(t *testing.T) {
+ t.Parallel()
+
+ // Test that an unknown path returns Unknown.
+ exePath := "/some/random/path/crush"
+ method := detectInstallMethod(exePath)
+ require.Equal(t, InstallMethodUnknown, method)
+}
+
+func TestDetectInstallMethod_NPM(t *testing.T) {
+ t.Parallel()
+
+ tests := []string{
+ "/usr/local/lib/node_modules/@charmland/crush/bin/crush",
+ "/home/user/.npm-global/lib/node_modules/@charmland/crush/bin/crush",
+ "/Users/user/node_modules/.bin/crush",
+ }
+
+ for _, exePath := range tests {
+ t.Run(exePath, func(t *testing.T) {
+ t.Parallel()
+ method := detectInstallMethod(exePath)
+ require.Equal(t, InstallMethodNPM, method)
+ })
+ }
+}
+
+func TestDetectInstallMethod_Homebrew(t *testing.T) {
+ t.Parallel()
+
+ // Test paths that work across platforms (contain /Cellar/).
+ tests := []struct {
+ name string
+ exePath string
+ }{
+ {"Intel Mac Cellar", "/usr/local/Cellar/crush/0.21.0/bin/crush"},
+ {"Apple Silicon Cellar", "/opt/homebrew/Cellar/crush/0.21.0/bin/crush"},
+ {"Linux Homebrew Cellar", "/home/linuxbrew/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
+ {"Linux Homebrew user Cellar", "/home/user/.linuxbrew/Cellar/crush/0.21.0/bin/crush"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ method := detectInstallMethod(tt.exePath)
+ require.Equal(t, InstallMethodHomebrew, method)
+ })
+ }
+}
+
+func TestDetectInstallMethod_DefaultGoPath(t *testing.T) {
+ // Cannot use t.Parallel() with t.Setenv.
+
+ // Test detection with default GOPATH (~/go/bin) when GOPATH is not set.
+ tmpDir := t.TempDir()
+ home := filepath.Join(tmpDir, "home", "user")
+ gopathBin := filepath.Join(home, "go", "bin")
+ require.NoError(t, os.MkdirAll(gopathBin, 0o755))
+
+ // Unset GOPATH to test default detection.
+ t.Setenv("GOPATH", "")
+ t.Setenv("HOME", home)
+
+ exePath := filepath.Join(gopathBin, "crush")
+ method := detectInstallMethod(exePath)
+ require.Equal(t, InstallMethodGoInstall, method)
+}
@@ -5,6 +5,9 @@ package update
import (
"debug/elf"
"fmt"
+ "os"
+ "path/filepath"
+ "strings"
)
const binaryName = "crush"
@@ -21,3 +24,73 @@ func validateBinary(path string) error {
}
return nil
}
+
+// detectInstallMethod determines how Crush was installed on Linux/BSD.
+func detectInstallMethod(exePath string) InstallMethod {
+ // Check for Nix installation first (works on any platform with Nix).
+ if strings.HasPrefix(exePath, "/nix/store/") {
+ return InstallMethodNix
+ }
+
+ // Check for apt (Debian/Ubuntu) installation.
+ // If binary is in /usr/bin and dpkg knows about it.
+ if strings.HasPrefix(exePath, "/usr/bin/") || strings.HasPrefix(exePath, "/usr/local/bin/") {
+ if _, err := os.Stat("/var/lib/dpkg/info/crush.list"); err == nil {
+ return InstallMethodApt
+ }
+ }
+
+ // Check for yum/dnf (Fedora/RHEL) installation.
+ if strings.HasPrefix(exePath, "/usr/bin/") || strings.HasPrefix(exePath, "/usr/local/bin/") {
+ // Check rpm database for crush package.
+ if _, err := os.Stat("/var/lib/rpm"); err == nil {
+ // We have an rpm database; if we're in /usr/bin, likely installed via yum.
+ // More precise detection would require running `rpm -qf` but we avoid
+ // spawning processes. Presence in /usr/bin with rpm db is a good signal.
+ if strings.HasPrefix(exePath, "/usr/bin/crush") {
+ // Check if there's no dpkg (to distinguish from apt).
+ if _, err := os.Stat("/var/lib/dpkg"); os.IsNotExist(err) {
+ return InstallMethodYum
+ }
+ }
+ }
+ }
+
+ // Check for AUR installation (Arch Linux).
+ // AUR packages are tracked by pacman in /var/lib/pacman.
+ if _, err := os.Stat("/var/lib/pacman/local"); err == nil {
+ // Arch-based system. Check if crush is in pacman's database.
+ entries, err := os.ReadDir("/var/lib/pacman/local")
+ if err == nil {
+ for _, entry := range entries {
+ if strings.HasPrefix(entry.Name(), "crush-") {
+ return InstallMethodAUR
+ }
+ }
+ }
+ }
+
+ // Check for Homebrew on Linux.
+ if strings.Contains(exePath, "/Cellar/") ||
+ strings.HasPrefix(exePath, "/home/linuxbrew/") ||
+ strings.Contains(exePath, "/.linuxbrew/") {
+ return InstallMethodHomebrew
+ }
+
+ // Check for npm global installation.
+ if strings.Contains(exePath, "node_modules") {
+ return InstallMethodNPM
+ }
+
+ // Check for go install.
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" {
+ home, _ := os.UserHomeDir()
+ gopath = filepath.Join(home, "go")
+ }
+ if strings.HasPrefix(exePath, filepath.Join(gopath, "bin")) {
+ return InstallMethodGoInstall
+ }
+
+ return InstallMethodUnknown
+}
@@ -5,6 +5,9 @@ package update
import (
"debug/pe"
"fmt"
+ "os"
+ "path/filepath"
+ "strings"
)
const binaryName = "crush.exe"
@@ -19,3 +22,62 @@ func validateBinary(path string) error {
// PE files opened successfully are valid executables.
return nil
}
+
+// detectInstallMethod determines how Crush was installed on Windows.
+func detectInstallMethod(exePath string) InstallMethod {
+ // Normalize path separators for comparison.
+ exePath = filepath.ToSlash(exePath)
+ exePathLower := strings.ToLower(exePath)
+
+ // Check for Scoop installation.
+ // Typically in ~/scoop/apps/crush/current/ or ~/scoop/shims/
+ home, _ := os.UserHomeDir()
+ if home != "" {
+ scoopPath := strings.ToLower(filepath.ToSlash(filepath.Join(home, "scoop")))
+ if strings.HasPrefix(exePathLower, scoopPath) {
+ return InstallMethodScoop
+ }
+ }
+
+ // Check for winget installation.
+ // Winget installs to %LOCALAPPDATA%\Microsoft\WinGet\Packages\
+ localAppData := os.Getenv("LOCALAPPDATA")
+ if localAppData != "" {
+ wingetPath := strings.ToLower(filepath.ToSlash(filepath.Join(localAppData, "Microsoft", "WinGet")))
+ if strings.HasPrefix(exePathLower, wingetPath) {
+ return InstallMethodWinget
+ }
+ }
+
+ // Also check Program Files for winget system-wide installs.
+ programFiles := os.Getenv("ProgramFiles")
+ if programFiles != "" {
+ pfPath := strings.ToLower(filepath.ToSlash(programFiles))
+ if strings.HasPrefix(exePathLower, pfPath) && strings.Contains(exePathLower, "charmbracelet") {
+ return InstallMethodWinget
+ }
+ }
+
+ // Check for npm global installation.
+ appData := os.Getenv("APPDATA")
+ if appData != "" {
+ npmPath := strings.ToLower(filepath.ToSlash(filepath.Join(appData, "npm")))
+ if strings.HasPrefix(exePathLower, npmPath) {
+ return InstallMethodNPM
+ }
+ }
+
+ // Check for go install.
+ gopath := os.Getenv("GOPATH")
+ if gopath == "" && home != "" {
+ gopath = filepath.Join(home, "go")
+ }
+ if gopath != "" {
+ gopathBin := strings.ToLower(filepath.ToSlash(filepath.Join(gopath, "bin")))
+ if strings.HasPrefix(exePathLower, gopathBin) {
+ return InstallMethodGoInstall
+ }
+ }
+
+ return InstallMethodUnknown
+}