update.go

  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"os"
  7	"runtime"
  8	"time"
  9
 10	"github.com/charmbracelet/crush/internal/format"
 11	"github.com/charmbracelet/crush/internal/update"
 12	"github.com/charmbracelet/crush/internal/version"
 13	"github.com/charmbracelet/x/exp/charmtone"
 14	"github.com/spf13/cobra"
 15)
 16
 17var updateCmd = &cobra.Command{
 18	Use:   "update",
 19	Short: "Check for and apply updates",
 20	Long: `Check if a new version of Crush is available.
 21Use 'update apply' to download and install the latest version.`,
 22	Example: `
 23# Check if an update is available
 24crush update
 25
 26# Apply the update if available
 27crush update apply
 28
 29# Force re-download even if already on latest version
 30crush update apply --force
 31  `,
 32	RunE: func(cmd *cobra.Command, args []string) error {
 33		ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
 34		defer cancel()
 35
 36		spinner := newUpdateSpinner(ctx, cancel, "Checking for updates")
 37		spinner.Start()
 38
 39		info, err := update.Check(ctx, version.Version, update.Default)
 40		spinner.Stop()
 41
 42		if err != nil {
 43			return fmt.Errorf("failed to check for updates: %w", err)
 44		}
 45
 46		if info.IsDevelopment() {
 47			fmt.Fprint(os.Stderr, info.DevelopmentVersionMessage(false))
 48			return nil
 49		}
 50
 51		if !info.Available() {
 52			fmt.Fprintf(os.Stderr, "You are already running the latest version (v%s).\n", info.Current)
 53			return nil
 54		}
 55
 56		// Check install method and provide appropriate instructions.
 57		method := update.DetectInstallMethod()
 58		if !method.CanSelfUpdate() {
 59			fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
 60			fmt.Fprintf(os.Stderr, "Crush was installed via %s. To update, run:\n", method)
 61			fmt.Fprintf(os.Stderr, "  %s\n", method.UpdateInstructions())
 62			return nil
 63		}
 64
 65		fmt.Fprintf(os.Stderr, "Update available: v%s → v%s\n", info.Current, info.Latest)
 66		fmt.Fprintf(os.Stderr, "Run 'crush update apply' to install the latest version.\n")
 67		fmt.Fprintf(os.Stderr, "Or visit %s to download manually.\n", info.URL)
 68
 69		return nil
 70	},
 71}
 72
 73var updateApplyCmd = &cobra.Command{
 74	Use:   "apply",
 75	Short: "Apply the latest update",
 76	Long:  `Download and install the latest version of Crush.`,
 77	Example: `
 78# Apply the latest update
 79crush update apply
 80
 81# Force re-download even if already on latest version
 82crush update apply --force
 83  `,
 84	RunE: func(cmd *cobra.Command, args []string) error {
 85		force, _ := cmd.Flags().GetBool("force")
 86
 87		ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Minute)
 88		defer cancel()
 89
 90		// Check install method first.
 91		method := update.DetectInstallMethod()
 92		if !method.CanSelfUpdate() {
 93			fmt.Fprintf(os.Stderr, "Crush was installed via %s.\n", method)
 94			fmt.Fprintf(os.Stderr, "Self-update is not supported for this installation method.\n")
 95			fmt.Fprintf(os.Stderr, "To update, run:\n")
 96			fmt.Fprintf(os.Stderr, "  %s\n", method.UpdateInstructions())
 97			return nil
 98		}
 99
100		spinner := newUpdateSpinner(ctx, cancel, "Checking for updates")
101		spinner.Start()
102
103		info, err := update.Check(ctx, version.Version, update.Default)
104		if err != nil {
105			spinner.Stop()
106			return fmt.Errorf("failed to check for updates: %w", err)
107		}
108
109		if info.IsDevelopment() {
110			spinner.Stop()
111			fmt.Fprint(os.Stderr, info.DevelopmentVersionMessage(true))
112			return nil
113		}
114
115		if !info.Available() && !force {
116			spinner.Stop()
117			fmt.Fprintf(os.Stderr, "You are already running the latest version (v%s).\n", info.Current)
118			fmt.Fprintf(os.Stderr, "Use --force to re-download and reinstall.\n")
119			return nil
120		}
121
122		// Find the appropriate asset for this platform.
123		asset, err := update.FindAsset(info.Release.Assets)
124		if err != nil {
125			spinner.Stop()
126			return fmt.Errorf("failed to find update for your platform: %w", err)
127		}
128
129		spinner.Stop()
130		spinner = newUpdateSpinner(ctx, cancel, fmt.Sprintf("Downloading v%s", info.Latest))
131		spinner.Start()
132
133		// Download the asset.
134		binaryPath, err := update.Download(ctx, asset, info.Release)
135		if err != nil {
136			spinner.Stop()
137			return fmt.Errorf("failed to download update: %w", err)
138		}
139		defer os.Remove(binaryPath)
140
141		spinner.Stop()
142		spinner = newUpdateSpinner(ctx, cancel, "Installing")
143		spinner.Start()
144
145		// Apply the update.
146		if err := update.Apply(binaryPath); err != nil {
147			spinner.Stop()
148			return fmt.Errorf("failed to apply update: %w", err)
149		}
150
151		spinner.Stop()
152
153		if force && !info.Available() {
154			fmt.Fprintf(os.Stderr, "Successfully reinstalled v%s!\n", info.Latest)
155		} else {
156			fmt.Fprintf(os.Stderr, "Successfully updated to v%s!\n", info.Latest)
157		}
158		if runtime.GOOS == "windows" {
159			fmt.Fprintf(os.Stderr, "Restart Crush to use the new version.\n")
160		} else {
161			fmt.Fprintf(os.Stderr, "Run 'crush -v' to verify the new version.\n")
162		}
163
164		return nil
165	},
166}
167
168// newUpdateSpinner creates a spinner for update operations.
169func newUpdateSpinner(ctx context.Context, cancel context.CancelFunc, label string) *format.SimpleSpinner {
170	return format.NewSimpleSpinner(ctx, cancel, label, charmtone.Dolly)
171}
172
173func init() {
174	updateApplyCmd.Flags().BoolP("force", "f", false, "Force re-download even if already on latest version")
175	updateCmd.AddCommand(updateApplyCmd)
176}