1package cmd
2
3import (
4 "context"
5 "fmt"
6
7 "github.com/charmbracelet/bubbles/spinner"
8 tea "github.com/charmbracelet/bubbletea"
9 "github.com/charmbracelet/lipgloss"
10 "github.com/spf13/cobra"
11
12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/cli/styles"
14 "github.com/bnema/dumber/internal/domain/entity"
15 "github.com/bnema/dumber/internal/infrastructure/config"
16 "github.com/bnema/dumber/internal/infrastructure/updater"
17)
18
19var updateForce bool
20
21var updateCmd = &cobra.Command{
22 Use: "update",
23 Short: "Check for and install updates",
24 Long: `Check for available updates and download/install them.
25
26Use --force to reinstall even if already up-to-date (skips version check).`,
27 RunE: runUpdate,
28}
29
30func init() {
31 rootCmd.AddCommand(updateCmd)
32 updateCmd.Flags().BoolVarP(&updateForce, "force", "f", false, "force reinstall (skips version check)")
33}
34
35// updateState represents the current state of the update process.
36type updateState int
37
38const (
39 stateChecking updateState = iota
40 stateDownloading
41 stateDone
42)
43
44// updateModel is the bubbletea model for the update command.
45type updateModel struct {
46 spinner spinner.Model
47 renderer *styles.UpdateRenderer
48 state updateState
49 force bool
50
51 // Results from check.
52 updateAvailable bool
53 canAutoUpdate bool
54 currentVersion string
55 latestVersion string
56 releaseURL string
57 downloadURL string
58
59 // Final result.
60 result string
61 err error
62 quitting bool
63 updateStaged bool // tracks if an update was actually downloaded and staged
64}
65
66// checkResultMsg is sent when the update check completes.
67type checkResultMsg struct {
68 output *usecase.CheckUpdateOutput
69 err error
70}
71
72// downloadResultMsg is sent when the download completes.
73type downloadResultMsg struct {
74 output *usecase.ApplyUpdateOutput
75 err error
76}
77
78func newUpdateModel(renderer *styles.UpdateRenderer, accentColor lipgloss.Color, force bool) updateModel {
79 s := spinner.New()
80 s.Spinner = spinner.Dot
81 s.Style = lipgloss.NewStyle().Foreground(accentColor)
82
83 return updateModel{
84 spinner: s,
85 renderer: renderer,
86 state: stateChecking,
87 force: force,
88 }
89}
90
91func (m updateModel) Init() tea.Cmd {
92 return tea.Batch(m.spinner.Tick, m.checkForUpdates())
93}
94
95func (m updateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
96 switch msg := msg.(type) {
97 case tea.KeyMsg:
98 switch msg.String() {
99 case "q", "ctrl+c":
100 m.quitting = true
101 return m, tea.Quit
102 }
103
104 case spinner.TickMsg:
105 var cmd tea.Cmd
106 m.spinner, cmd = m.spinner.Update(msg)
107 return m, cmd
108
109 case checkResultMsg:
110 if msg.err != nil {
111 m.err = msg.err
112 m.state = stateDone
113 return m, tea.Quit
114 }
115
116 m.updateAvailable = msg.output.UpdateAvailable
117 m.canAutoUpdate = msg.output.CanAutoUpdate
118 m.currentVersion = msg.output.CurrentVersion
119 m.latestVersion = msg.output.LatestVersion
120 m.releaseURL = msg.output.ReleaseURL
121 m.downloadURL = msg.output.DownloadURL
122
123 // Handle dev builds when force is used.
124 if m.force && (m.currentVersion == "" || m.currentVersion == "dev") {
125 m.result = m.renderer.RenderDevBuild()
126 m.state = stateDone
127 return m, tea.Quit
128 }
129
130 // Decide next action.
131 switch {
132 case !m.updateAvailable && !m.force:
133 // Already up to date.
134 m.result = m.renderer.RenderUpToDate(m.currentVersion)
135 m.state = stateDone
136 return m, tea.Quit
137
138 case m.updateAvailable && !m.canAutoUpdate:
139 // Update available but can't auto-update.
140 m.result = m.renderer.RenderCannotAutoUpdate(m.currentVersion, m.latestVersion, m.releaseURL)
141 m.state = stateDone
142 return m, tea.Quit
143
144 default:
145 // Proceed with download (either update available or force mode).
146 m.state = stateDownloading
147 return m, m.downloadUpdate()
148 }
149
150 case downloadResultMsg:
151 m.state = stateDone
152 if msg.err != nil {
153 m.err = msg.err
154 return m, tea.Quit
155 }
156
157 if msg.output.Status == entity.UpdateStatusFailed {
158 m.result = m.renderer.RenderError(fmt.Errorf("%s", msg.output.Message))
159 } else {
160 m.result = m.renderer.RenderStaged(m.latestVersion)
161 m.updateStaged = true
162 }
163 return m, tea.Quit
164 }
165
166 return m, nil
167}
168
169func (m updateModel) View() string {
170 if m.quitting {
171 return ""
172 }
173
174 if m.err != nil {
175 return m.renderer.RenderError(m.err)
176 }
177
178 if m.state == stateDone {
179 return m.result
180 }
181
182 switch m.state {
183 case stateChecking:
184 return m.renderer.RenderChecking(m.spinner.View())
185 case stateDownloading:
186 version := m.latestVersion
187 if m.force && !m.updateAvailable {
188 version = m.currentVersion
189 }
190 return m.renderer.RenderDownloading(m.spinner.View(), version)
191 default:
192 return ""
193 }
194}
195
196func (updateModel) checkForUpdates() tea.Cmd {
197 return func() tea.Msg {
198 ctx := context.Background()
199
200 checker := updater.NewGitHubChecker()
201 applier, err := updater.NewApplierFromXDG()
202 if err != nil {
203 return checkResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
204 }
205
206 cliApp := GetApp()
207 if cliApp == nil {
208 return checkResultMsg{err: fmt.Errorf("app not initialized")}
209 }
210
211 checkUC := usecase.NewCheckUpdateUseCase(checker, applier, cliApp.BuildInfo)
212 result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{})
213
214 return checkResultMsg{output: result, err: err}
215 }
216}
217
218func (m updateModel) downloadUpdate() tea.Cmd {
219 return func() tea.Msg {
220 ctx := context.Background()
221
222 downloader := updater.NewGitHubDownloader()
223 applier, err := updater.NewApplierFromXDG()
224 if err != nil {
225 return downloadResultMsg{err: fmt.Errorf("failed to create applier: %w", err)}
226 }
227
228 dirs, err := config.GetXDGDirs()
229 if err != nil {
230 return downloadResultMsg{err: fmt.Errorf("failed to get cache dir: %w", err)}
231 }
232
233 applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
234 result, err := applyUC.Execute(ctx, usecase.ApplyUpdateInput{
235 DownloadURL: m.downloadURL,
236 })
237
238 return downloadResultMsg{output: result, err: err}
239 }
240}
241
242func runUpdate(_ *cobra.Command, _ []string) error {
243 cliApp := GetApp()
244 if cliApp == nil {
245 return fmt.Errorf("app not initialized")
246 }
247
248 renderer := styles.NewUpdateRenderer(cliApp.Theme)
249
250 // Create and run the bubbletea program.
251 m := newUpdateModel(renderer, cliApp.Theme.Accent, updateForce)
252 p := tea.NewProgram(m)
253
254 finalModel, err := p.Run()
255 if err != nil {
256 return fmt.Errorf("update failed: %w", err)
257 }
258
259 // Check if the update was staged and we need to finalize on exit.
260 if model, ok := finalModel.(updateModel); ok {
261 if model.updateStaged {
262 // If update was successfully staged, finalize it.
263 if err := finalizeUpdate(); err != nil {
264 fmt.Println(renderer.RenderError(err))
265 }
266 }
267 }
268
269 return nil
270}
271
272// finalizeUpdate applies any staged update.
273func finalizeUpdate() error {
274 ctx := context.Background()
275
276 downloader := updater.NewGitHubDownloader()
277 applier, err := updater.NewApplierFromXDG()
278 if err != nil {
279 return fmt.Errorf("failed to create applier: %w", err)
280 }
281
282 dirs, err := config.GetXDGDirs()
283 if err != nil {
284 return fmt.Errorf("failed to get cache dir: %w", err)
285 }
286
287 applyUC := usecase.NewApplyUpdateUseCase(downloader, applier, dirs.CacheHome)
288 return applyUC.FinalizeOnExit(ctx)
289}