From 5a4ec72dfa0f01bb4524d66a77d16e121d13b7ed Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 8 Dec 2025 20:55:16 -0700 Subject: [PATCH] feat(update): auto-update in background on startup On startup, Crush now checks for updates in the background and automatically downloads/applies them if CanSelfUpdate() is true. Users are notified to restart. Package manager installs show platform-specific upgrade instructions instead. - Add DisableAutoUpdate config option and CRUSH_DISABLE_AUTO_UPDATE env var - Add platform detection for Homebrew, npm, AUR, Nix, apt, yum, winget, Scoop - Fix Windows winget detection (require ProgramFiles prefix) - Add --force flag to crush update apply - Add tests for version comparison edge cases and platform detection Assisted-by: Claude Opus 4.5 via Crush --- go.mod | 1 + go.sum | 2 + internal/cmd/root.go | 87 +++++++++++ internal/cmd/update.go | 58 +++++-- internal/config/config.go | 1 + internal/config/load.go | 4 + internal/update/update.go | 148 +++++++++++++++--- internal/update/update_darwin.go | 38 +++++ internal/update/update_test.go | 241 +++++++++++++++++++++++++++++- internal/update/update_unix.go | 73 +++++++++ internal/update/update_windows.go | 62 ++++++++ 11 files changed, 680 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index cbfaa3077007fce91f5c7602c77c7f2353a4682a..a91de44f536eedb8bbd5e716b6e61038d681aabb 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 238b46788328c5ba0ada41bc882fd0d40949f09e..4086d13d5d4c9baccf500ee7ff75117300390d1f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 64f81423a5e109dbfcc4cee2238c69dc6da03f54..94d17c72703d2e94bbbeb41a00b9b82fb000678c 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -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) diff --git a/internal/cmd/update.go b/internal/cmd/update.go index 0fcd611567feff0e07b3ffb9dc6dfdbe98341390..863df7a501a5b2dad9b5365461b210005326092d 100644 --- a/internal/cmd/update.go +++ b/internal/cmd/update.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 464dc14bc8c6d12cdf1db17c681c4faa68a59339..a6caabaec6c95d984dc9fa832bc358ad0c0dd56d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/load.go b/internal/config/load.go index 7645861198eefbceb1e283ee7815d3f130b0b868..bb45598d006a488071f840d2d77bc2bcba6df294 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -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, diff --git a/internal/update/update.go b/internal/update/update.go index 969d10028b7662231571a41fd2f3122ce06a0891..19f5306e1ed59446e7e1cbf6a08cf6d3bb2d4887 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 } diff --git a/internal/update/update_darwin.go b/internal/update/update_darwin.go index 7b88c3dc22bae5484c62d765cdbf43413b4c22fd..c9966e3b0dbc2b929828449c91b246550651cafe 100644 --- a/internal/update/update_darwin.go +++ b/internal/update/update_darwin.go @@ -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 +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go index c02cae91ca1fe6c7ba7cbcbd16db8112215c81ca..e886caeed04b175437dad12f04e0aeebfff4de7e 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -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) +} diff --git a/internal/update/update_unix.go b/internal/update/update_unix.go index 369ce171c359a78140b856212ee3eca69bd93927..fbd3f72fe8e4e82b410870fe4f495d340920abcd 100644 --- a/internal/update/update_unix.go +++ b/internal/update/update_unix.go @@ -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 +} diff --git a/internal/update/update_windows.go b/internal/update/update_windows.go index 9270f4f840e2295e9286bde218c3d9a25da554c4..b66c2f10f0c859093f8ba63833da36e92bc4bf9d 100644 --- a/internal/update/update_windows.go +++ b/internal/update/update_windows.go @@ -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 +}