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