Merge pull request #44 from bnema/feature/cli-self-update
feat(cli): add self-update command with --force flag
5 files · +415 -0
M CHANGELOG.md +5 -0
| @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. | |||
| 5 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line. | |
| 9 | + | - Interactive spinner animation during check and download phases. | |
| 10 | + | - `--force` / `-f` flag to skip version check and reinstall the current version. | |
| 11 | + | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | |
| 12 | + | - Theme-aware styling consistent with other CLI commands. | |
| 8 | 13 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | |
| 9 | 14 | - `Ctrl+P → m`: Opens a tab picker modal. | |
| 10 | 15 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | |
| @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. | |||||
| 5 | ## [Unreleased] | 5 | ## [Unreleased] | ||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | + | - **CLI self-update command**: New `dumber update` command to check for and install updates from the command line. | |||
| 9 | + | - Interactive spinner animation during check and download phases. | |||
| 10 | + | - `--force` / `-f` flag to skip version check and reinstall the current version. | |||
| 11 | + | - Reuses existing auto-update infrastructure (check, download, stage, apply on exit). | |||
| 12 | + | - Theme-aware styling consistent with other CLI commands. | |||
| 8 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | 13 | - **Move pane to tab**: New pane-mode actions to move the active pane to another tab. | ||
| 9 | - `Ctrl+P → m`: Opens a tab picker modal. | 14 | - `Ctrl+P → m`: Opens a tab picker modal. | ||
| 10 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | 15 | - `Ctrl+P → M`: Moves the active pane to the next tab (creates a new tab if needed). | ||
M README.md +3 -0
| @@ -244,6 +244,9 @@ sudo apt install va-driver-all | |||
| 244 | 244 | - `dumber browse "!g golang"` # search via bang shortcut | |
| 245 | 245 | - Show version information: | |
| 246 | 246 | - `dumber about` # version, commit, and build date | |
| 247 | + | - Check for and install updates: | |
| 248 | + | - `dumber update` # check and install if available | |
| 249 | + | - `dumber update --force` # force reinstall (skips version check) | |
| 247 | 250 | - Launcher integration (dmenu‑style examples with favicon support): | |
| 248 | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | |
| 249 | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | |
| @@ -244,6 +244,9 @@ sudo apt install va-driver-all | |||||
| 244 | - `dumber browse "!g golang"` # search via bang shortcut | 244 | - `dumber browse "!g golang"` # search via bang shortcut | ||
| 245 | - Show version information: | 245 | - Show version information: | ||
| 246 | - `dumber about` # version, commit, and build date | 246 | - `dumber about` # version, commit, and build date | ||
| 247 | + | - Check for and install updates: | |||
| 248 | + | - `dumber update` # check and install if available | |||
| 249 | + | - `dumber update --force` # force reinstall (skips version check) | |||
| 247 | - Launcher integration (dmenu‑style examples with favicon support): | 250 | - Launcher integration (dmenu‑style examples with favicon support): | ||
| 248 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | ||
| 249 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | ||
A internal/cli/cmd/update.go +289 -0
| @@ -0,0 +1,289 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 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 | + | ||
| 19 | + | var updateForce bool | |
| 20 | + | ||
| 21 | + | var updateCmd = &cobra.Command{ | |
| 22 | + | Use: "update", | |
| 23 | + | Short: "Check for and install updates", | |
| 24 | + | Long: `Check for available updates and download/install them. | |
| 25 | + | ||
| 26 | + | Use --force to reinstall even if already up-to-date (skips version check).`, | |
| 27 | + | RunE: runUpdate, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func 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. | |
| 36 | + | type updateState int | |
| 37 | + | ||
| 38 | + | const ( | |
| 39 | + | stateChecking updateState = iota | |
| 40 | + | stateDownloading | |
| 41 | + | stateDone | |
| 42 | + | ) | |
| 43 | + | ||
| 44 | + | // updateModel is the bubbletea model for the update command. | |
| 45 | + | type 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. | |
| 67 | + | type checkResultMsg struct { | |
| 68 | + | output *usecase.CheckUpdateOutput | |
| 69 | + | err error | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // downloadResultMsg is sent when the download completes. | |
| 73 | + | type downloadResultMsg struct { | |
| 74 | + | output *usecase.ApplyUpdateOutput | |
| 75 | + | err error | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func 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 | + | ||
| 91 | + | func (m updateModel) Init() tea.Cmd { | |
| 92 | + | return tea.Batch(m.spinner.Tick, m.checkForUpdates()) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func (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 | + | ||
| 169 | + | func (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 | + | ||
| 196 | + | func (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 | + | app := GetApp() | |
| 207 | + | if app == nil { | |
| 208 | + | return checkResultMsg{err: fmt.Errorf("app not initialized")} | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |
| 212 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |
| 213 | + | ||
| 214 | + | return checkResultMsg{output: result, err: err} | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func (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 | + | ||
| 242 | + | func runUpdate(_ *cobra.Command, _ []string) error { | |
| 243 | + | app := GetApp() | |
| 244 | + | if app == nil { | |
| 245 | + | return fmt.Errorf("app not initialized") | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | renderer := styles.NewUpdateRenderer(app.Theme) | |
| 249 | + | ||
| 250 | + | // Create and run the bubbletea program. | |
| 251 | + | m := newUpdateModel(renderer, app.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. | |
| 273 | + | func 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 | + | } | |
| @@ -0,0 +1,289 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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 | + | ||||
| 19 | + | var updateForce bool | |||
| 20 | + | ||||
| 21 | + | var updateCmd = &cobra.Command{ | |||
| 22 | + | Use: "update", | |||
| 23 | + | Short: "Check for and install updates", | |||
| 24 | + | Long: `Check for available updates and download/install them. | |||
| 25 | + | ||||
| 26 | + | Use --force to reinstall even if already up-to-date (skips version check).`, | |||
| 27 | + | RunE: runUpdate, | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func 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. | |||
| 36 | + | type updateState int | |||
| 37 | + | ||||
| 38 | + | const ( | |||
| 39 | + | stateChecking updateState = iota | |||
| 40 | + | stateDownloading | |||
| 41 | + | stateDone | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | // updateModel is the bubbletea model for the update command. | |||
| 45 | + | type 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. | |||
| 67 | + | type checkResultMsg struct { | |||
| 68 | + | output *usecase.CheckUpdateOutput | |||
| 69 | + | err error | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // downloadResultMsg is sent when the download completes. | |||
| 73 | + | type downloadResultMsg struct { | |||
| 74 | + | output *usecase.ApplyUpdateOutput | |||
| 75 | + | err error | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func 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 | + | ||||
| 91 | + | func (m updateModel) Init() tea.Cmd { | |||
| 92 | + | return tea.Batch(m.spinner.Tick, m.checkForUpdates()) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func (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 | + | ||||
| 169 | + | func (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 | + | ||||
| 196 | + | func (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 | + | app := GetApp() | |||
| 207 | + | if app == nil { | |||
| 208 | + | return checkResultMsg{err: fmt.Errorf("app not initialized")} | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | checkUC := usecase.NewCheckUpdateUseCase(checker, applier, app.BuildInfo) | |||
| 212 | + | result, err := checkUC.Execute(ctx, usecase.CheckUpdateInput{}) | |||
| 213 | + | ||||
| 214 | + | return checkResultMsg{output: result, err: err} | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | ||||
| 218 | + | func (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 | + | ||||
| 242 | + | func runUpdate(_ *cobra.Command, _ []string) error { | |||
| 243 | + | app := GetApp() | |||
| 244 | + | if app == nil { | |||
| 245 | + | return fmt.Errorf("app not initialized") | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | renderer := styles.NewUpdateRenderer(app.Theme) | |||
| 249 | + | ||||
| 250 | + | // Create and run the bubbletea program. | |||
| 251 | + | m := newUpdateModel(renderer, app.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. | |||
| 273 | + | func 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 | + | } | |||
M internal/cli/styles/icons.go +5 -0
| @@ -53,4 +53,9 @@ const ( | |||
| 53 | 53 | IconRestore = "\uf0e2" // rotate-left (restore) | |
| 54 | 54 | IconExpand = "\uf065" // expand | |
| 55 | 55 | IconCollapse = "\uf066" // compress | |
| 56 | + | ||
| 57 | + | // Update / download | |
| 58 | + | IconDownload = "\uf019" // download | |
| 59 | + | IconUpdate = "\uf021" // refresh/sync | |
| 60 | + | IconRocket = "\uf135" // rocket (upgrade) | |
| 56 | 61 | ) | |
| @@ -53,4 +53,9 @@ const ( | |||||
| 53 | IconRestore = "\uf0e2" // rotate-left (restore) | 53 | IconRestore = "\uf0e2" // rotate-left (restore) | ||
| 54 | IconExpand = "\uf065" // expand | 54 | IconExpand = "\uf065" // expand | ||
| 55 | IconCollapse = "\uf066" // compress | 55 | IconCollapse = "\uf066" // compress | ||
| 56 | + | ||||
| 57 | + | // Update / download | |||
| 58 | + | IconDownload = "\uf019" // download | |||
| 59 | + | IconUpdate = "\uf021" // refresh/sync | |||
| 60 | + | IconRocket = "\uf135" // rocket (upgrade) | |||
| 56 | ) | 61 | ) | ||
A internal/cli/styles/update.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/charmbracelet/lipgloss" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // UpdateRenderer renders update status messages with styled output. | |
| 10 | + | type UpdateRenderer struct { | |
| 11 | + | theme *Theme | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // NewUpdateRenderer creates a new update renderer with the given theme. | |
| 15 | + | func NewUpdateRenderer(theme *Theme) *UpdateRenderer { | |
| 16 | + | return &UpdateRenderer{theme: theme} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // RenderChecking renders the "checking for updates" message. | |
| 20 | + | func (*UpdateRenderer) RenderChecking(spinner string) string { | |
| 21 | + | return fmt.Sprintf("\n %s Checking for updates...\n", spinner) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // RenderUpToDate renders the "already up to date" message. | |
| 25 | + | func (r *UpdateRenderer) RenderUpToDate(version string) string { | |
| 26 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 27 | + | versionStyle := r.theme.Highlight | |
| 28 | + | ||
| 29 | + | return fmt.Sprintf( | |
| 30 | + | "\n %s Already up to date (%s)\n", | |
| 31 | + | iconStyle.Render(IconCheck), | |
| 32 | + | versionStyle.Render(version), | |
| 33 | + | ) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // RenderAvailable renders the "update available" message. | |
| 37 | + | func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string { | |
| 38 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 39 | + | versionStyle := r.theme.Highlight | |
| 40 | + | urlStyle := r.theme.Subtle | |
| 41 | + | ||
| 42 | + | return fmt.Sprintf( | |
| 43 | + | "\n %s Update available: %s %s %s\n %s\n", | |
| 44 | + | iconStyle.Render(IconRocket), | |
| 45 | + | versionStyle.Render(current), | |
| 46 | + | iconStyle.Render(IconArrow), | |
| 47 | + | versionStyle.Render(latest), | |
| 48 | + | urlStyle.Render(releaseURL), | |
| 49 | + | ) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // RenderDownloading renders the "downloading" message with spinner. | |
| 53 | + | func (r *UpdateRenderer) RenderDownloading(spinner, version string) string { | |
| 54 | + | versionStyle := r.theme.Highlight | |
| 55 | + | ||
| 56 | + | return fmt.Sprintf( | |
| 57 | + | "\n %s Downloading %s...\n", | |
| 58 | + | spinner, | |
| 59 | + | versionStyle.Render(version), | |
| 60 | + | ) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // RenderStaged renders the "update staged" success message. | |
| 64 | + | func (r *UpdateRenderer) RenderStaged(version string) string { | |
| 65 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 66 | + | versionStyle := r.theme.Highlight | |
| 67 | + | ||
| 68 | + | return fmt.Sprintf( | |
| 69 | + | "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n", | |
| 70 | + | iconStyle.Render(IconCheck), | |
| 71 | + | versionStyle.Render(version), | |
| 72 | + | iconStyle.Render(IconCheck), | |
| 73 | + | ) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // RenderError renders an error message. | |
| 77 | + | func (r *UpdateRenderer) RenderError(err error) string { | |
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 79 | + | ||
| 80 | + | return fmt.Sprintf( | |
| 81 | + | "\n %s Update failed: %v\n", | |
| 82 | + | iconStyle.Render(IconX), | |
| 83 | + | err, | |
| 84 | + | ) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // RenderCannotAutoUpdate renders the "cannot auto-update" message. | |
| 88 | + | func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string { | |
| 89 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 90 | + | versionStyle := r.theme.Highlight | |
| 91 | + | urlStyle := r.theme.Subtle | |
| 92 | + | ||
| 93 | + | return fmt.Sprintf( | |
| 94 | + | "\n %s Update available: %s %s %s\n"+ | |
| 95 | + | " %s Cannot auto-update: binary is not writable\n"+ | |
| 96 | + | " Download manually: %s\n", | |
| 97 | + | iconStyle.Render(IconRocket), | |
| 98 | + | versionStyle.Render(current), | |
| 99 | + | iconStyle.Render(IconArrow), | |
| 100 | + | versionStyle.Render(latest), | |
| 101 | + | iconStyle.Render(IconWarning), | |
| 102 | + | urlStyle.Render(releaseURL), | |
| 103 | + | ) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // RenderDevBuild renders the "dev build" skip message. | |
| 107 | + | func (r *UpdateRenderer) RenderDevBuild() string { | |
| 108 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 109 | + | return fmt.Sprintf( | |
| 110 | + | "\n %s Development build - update check skipped\n", | |
| 111 | + | iconStyle.Render(IconInfo), | |
| 112 | + | ) | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/charmbracelet/lipgloss" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // UpdateRenderer renders update status messages with styled output. | |||
| 10 | + | type UpdateRenderer struct { | |||
| 11 | + | theme *Theme | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // NewUpdateRenderer creates a new update renderer with the given theme. | |||
| 15 | + | func NewUpdateRenderer(theme *Theme) *UpdateRenderer { | |||
| 16 | + | return &UpdateRenderer{theme: theme} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // RenderChecking renders the "checking for updates" message. | |||
| 20 | + | func (*UpdateRenderer) RenderChecking(spinner string) string { | |||
| 21 | + | return fmt.Sprintf("\n %s Checking for updates...\n", spinner) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // RenderUpToDate renders the "already up to date" message. | |||
| 25 | + | func (r *UpdateRenderer) RenderUpToDate(version string) string { | |||
| 26 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 27 | + | versionStyle := r.theme.Highlight | |||
| 28 | + | ||||
| 29 | + | return fmt.Sprintf( | |||
| 30 | + | "\n %s Already up to date (%s)\n", | |||
| 31 | + | iconStyle.Render(IconCheck), | |||
| 32 | + | versionStyle.Render(version), | |||
| 33 | + | ) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // RenderAvailable renders the "update available" message. | |||
| 37 | + | func (r *UpdateRenderer) RenderAvailable(current, latest, releaseURL string) string { | |||
| 38 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 39 | + | versionStyle := r.theme.Highlight | |||
| 40 | + | urlStyle := r.theme.Subtle | |||
| 41 | + | ||||
| 42 | + | return fmt.Sprintf( | |||
| 43 | + | "\n %s Update available: %s %s %s\n %s\n", | |||
| 44 | + | iconStyle.Render(IconRocket), | |||
| 45 | + | versionStyle.Render(current), | |||
| 46 | + | iconStyle.Render(IconArrow), | |||
| 47 | + | versionStyle.Render(latest), | |||
| 48 | + | urlStyle.Render(releaseURL), | |||
| 49 | + | ) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // RenderDownloading renders the "downloading" message with spinner. | |||
| 53 | + | func (r *UpdateRenderer) RenderDownloading(spinner, version string) string { | |||
| 54 | + | versionStyle := r.theme.Highlight | |||
| 55 | + | ||||
| 56 | + | return fmt.Sprintf( | |||
| 57 | + | "\n %s Downloading %s...\n", | |||
| 58 | + | spinner, | |||
| 59 | + | versionStyle.Render(version), | |||
| 60 | + | ) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // RenderStaged renders the "update staged" success message. | |||
| 64 | + | func (r *UpdateRenderer) RenderStaged(version string) string { | |||
| 65 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 66 | + | versionStyle := r.theme.Highlight | |||
| 67 | + | ||||
| 68 | + | return fmt.Sprintf( | |||
| 69 | + | "\n %s Update %s downloaded and staged\n %s Will be applied when this command exits\n", | |||
| 70 | + | iconStyle.Render(IconCheck), | |||
| 71 | + | versionStyle.Render(version), | |||
| 72 | + | iconStyle.Render(IconCheck), | |||
| 73 | + | ) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // RenderError renders an error message. | |||
| 77 | + | func (r *UpdateRenderer) RenderError(err error) string { | |||
| 78 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |||
| 79 | + | ||||
| 80 | + | return fmt.Sprintf( | |||
| 81 | + | "\n %s Update failed: %v\n", | |||
| 82 | + | iconStyle.Render(IconX), | |||
| 83 | + | err, | |||
| 84 | + | ) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // RenderCannotAutoUpdate renders the "cannot auto-update" message. | |||
| 88 | + | func (r *UpdateRenderer) RenderCannotAutoUpdate(current, latest, releaseURL string) string { | |||
| 89 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |||
| 90 | + | versionStyle := r.theme.Highlight | |||
| 91 | + | urlStyle := r.theme.Subtle | |||
| 92 | + | ||||
| 93 | + | return fmt.Sprintf( | |||
| 94 | + | "\n %s Update available: %s %s %s\n"+ | |||
| 95 | + | " %s Cannot auto-update: binary is not writable\n"+ | |||
| 96 | + | " Download manually: %s\n", | |||
| 97 | + | iconStyle.Render(IconRocket), | |||
| 98 | + | versionStyle.Render(current), | |||
| 99 | + | iconStyle.Render(IconArrow), | |||
| 100 | + | versionStyle.Render(latest), | |||
| 101 | + | iconStyle.Render(IconWarning), | |||
| 102 | + | urlStyle.Render(releaseURL), | |||
| 103 | + | ) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // RenderDevBuild renders the "dev build" skip message. | |||
| 107 | + | func (r *UpdateRenderer) RenderDevBuild() string { | |||
| 108 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |||
| 109 | + | return fmt.Sprintf( | |||
| 110 | + | "\n %s Development build - update check skipped\n", | |||
| 111 | + | iconStyle.Render(IconInfo), | |||
| 112 | + | ) | |||
| 113 | + | } | |||