feat(cli): add config command with migrate subcommand
- commit
- 8f1f1655122e73309a690a18af8aadc10948acab
- author
- bnema · · 2025-12-27 18:48 UTC
- parent
- ffd31dd
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
2 files · +439 -0
A internal/cli/cmd/config.go +299 -0
| @@ -0,0 +1,299 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "os" | |
| 7 | + | ||
| 8 | + | "github.com/charmbracelet/bubbles/spinner" | |
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |
| 10 | + | "github.com/charmbracelet/lipgloss" | |
| 11 | + | "github.com/spf13/cobra" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | + | "github.com/bnema/dumber/internal/cli/styles" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var configYes bool | |
| 20 | + | ||
| 21 | + | var configCmd = &cobra.Command{ | |
| 22 | + | Use: "config", | |
| 23 | + | Short: "Manage configuration", | |
| 24 | + | Long: `View configuration status and migrate to add new default settings.`, | |
| 25 | + | RunE: runConfigStatus, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | var configMigrateCmd = &cobra.Command{ | |
| 29 | + | Use: "migrate", | |
| 30 | + | Short: "Add missing default settings to config file", | |
| 31 | + | Long: `Compares your config file with available defaults and adds any missing settings. | |
| 32 | + | ||
| 33 | + | Existing settings are never modified - only missing keys are added with default values.`, | |
| 34 | + | RunE: runConfigMigrate, | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func init() { | |
| 38 | + | rootCmd.AddCommand(configCmd) | |
| 39 | + | configCmd.AddCommand(configMigrateCmd) | |
| 40 | + | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // runConfigStatus shows config file path and migration status. | |
| 44 | + | func runConfigStatus(_ *cobra.Command, _ []string) error { | |
| 45 | + | app := GetApp() | |
| 46 | + | if app == nil { | |
| 47 | + | return fmt.Errorf("app not initialized") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 51 | + | migrator := config.NewMigrator() | |
| 52 | + | uc := usecase.NewMigrateConfigUseCase(migrator) | |
| 53 | + | ||
| 54 | + | // Get config file path | |
| 55 | + | configFile, err := config.GetConfigFile() | |
| 56 | + | if err != nil { | |
| 57 | + | fmt.Println(renderer.RenderError(err)) | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Check if config file exists | |
| 62 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 63 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Check for migration | |
| 68 | + | ctx := context.Background() | |
| 69 | + | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |
| 70 | + | if err != nil { | |
| 71 | + | fmt.Println(renderer.RenderError(err)) | |
| 72 | + | return nil | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if !result.NeedsMigration { | |
| 76 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Show status with missing count | |
| 81 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |
| 82 | + | fmt.Println(renderer.RenderMigrateHint()) | |
| 83 | + | ||
| 84 | + | return nil | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // runConfigMigrate runs the migration with optional confirmation. | |
| 88 | + | func runConfigMigrate(_ *cobra.Command, _ []string) error { | |
| 89 | + | app := GetApp() | |
| 90 | + | if app == nil { | |
| 91 | + | return fmt.Errorf("app not initialized") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | renderer := styles.NewConfigRenderer(app.Theme) | |
| 95 | + | migrator := config.NewMigrator() | |
| 96 | + | uc := usecase.NewMigrateConfigUseCase(migrator) | |
| 97 | + | ||
| 98 | + | // Get config file path | |
| 99 | + | configFile, err := config.GetConfigFile() | |
| 100 | + | if err != nil { | |
| 101 | + | fmt.Println(renderer.RenderError(err)) | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Check if config file exists | |
| 106 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |
| 107 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Check for migration | |
| 112 | + | ctx := context.Background() | |
| 113 | + | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |
| 114 | + | if err != nil { | |
| 115 | + | fmt.Println(renderer.RenderError(err)) | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | if !result.NeedsMigration { | |
| 120 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // Show config info and missing keys | |
| 125 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |
| 126 | + | fmt.Println(renderer.RenderMissingKeys(result.MissingKeys)) | |
| 127 | + | ||
| 128 | + | // If --yes flag, proceed without confirmation | |
| 129 | + | if configYes { | |
| 130 | + | return executeMigration(ctx, uc, renderer) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Show confirmation dialog | |
| 134 | + | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // executeMigration performs the actual migration. | |
| 138 | + | func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error { | |
| 139 | + | result, err := uc.Execute(ctx, usecase.MigrateConfigInput{}) | |
| 140 | + | if err != nil { | |
| 141 | + | fmt.Println(renderer.RenderError(err)) | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | if len(result.AddedKeys) > 0 { | |
| 146 | + | fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile)) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | return nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // migrateState represents the current state of the migrate confirmation. | |
| 153 | + | type migrateState int | |
| 154 | + | ||
| 155 | + | const ( | |
| 156 | + | migrateStateConfirm migrateState = iota | |
| 157 | + | migrateStateDone | |
| 158 | + | ) | |
| 159 | + | ||
| 160 | + | // migrateModel is the bubbletea model for the migrate confirmation. | |
| 161 | + | type migrateModel struct { | |
| 162 | + | spinner spinner.Model | |
| 163 | + | renderer *styles.ConfigRenderer | |
| 164 | + | confirm styles.ConfirmModel | |
| 165 | + | state migrateState | |
| 166 | + | uc *usecase.MigrateConfigUseCase | |
| 167 | + | missingKeys []port.KeyInfo | |
| 168 | + | ||
| 169 | + | result string | |
| 170 | + | err error | |
| 171 | + | quitting bool | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // migrateResultMsg is sent when the migration completes. | |
| 175 | + | type migrateResultMsg struct { | |
| 176 | + | output *usecase.MigrateConfigOutput | |
| 177 | + | err error | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func newMigrateModel( | |
| 181 | + | renderer *styles.ConfigRenderer, | |
| 182 | + | theme *styles.Theme, | |
| 183 | + | uc *usecase.MigrateConfigUseCase, | |
| 184 | + | missingKeys []port.KeyInfo, | |
| 185 | + | ) migrateModel { | |
| 186 | + | s := spinner.New() | |
| 187 | + | s.Spinner = spinner.Dot | |
| 188 | + | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |
| 189 | + | ||
| 190 | + | confirm := styles.NewConfirm(theme, "Add these settings with default values?") | |
| 191 | + | ||
| 192 | + | return migrateModel{ | |
| 193 | + | spinner: s, | |
| 194 | + | renderer: renderer, | |
| 195 | + | confirm: confirm, | |
| 196 | + | state: migrateStateConfirm, | |
| 197 | + | uc: uc, | |
| 198 | + | missingKeys: missingKeys, | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (m migrateModel) Init() tea.Cmd { | |
| 203 | + | return m.spinner.Tick | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 207 | + | switch msg := msg.(type) { | |
| 208 | + | case tea.KeyMsg: | |
| 209 | + | switch msg.String() { | |
| 210 | + | case "ctrl+c": | |
| 211 | + | m.quitting = true | |
| 212 | + | return m, tea.Quit | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | case spinner.TickMsg: | |
| 216 | + | var cmd tea.Cmd | |
| 217 | + | m.spinner, cmd = m.spinner.Update(msg) | |
| 218 | + | return m, cmd | |
| 219 | + | ||
| 220 | + | case migrateResultMsg: | |
| 221 | + | m.state = migrateStateDone | |
| 222 | + | if msg.err != nil { | |
| 223 | + | m.err = msg.err | |
| 224 | + | return m, tea.Quit | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | if len(msg.output.AddedKeys) > 0 { | |
| 228 | + | m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile) | |
| 229 | + | } | |
| 230 | + | return m, tea.Quit | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Handle confirm dialog | |
| 234 | + | if m.state == migrateStateConfirm { | |
| 235 | + | var cmd tea.Cmd | |
| 236 | + | m.confirm, cmd = m.confirm.Update(msg) | |
| 237 | + | ||
| 238 | + | if m.confirm.Done() { | |
| 239 | + | if m.confirm.Result() { | |
| 240 | + | // User confirmed, run migration | |
| 241 | + | return m, m.runMigration() | |
| 242 | + | } | |
| 243 | + | // User canceled | |
| 244 | + | m.quitting = true | |
| 245 | + | return m, tea.Quit | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | return m, cmd | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | return m, nil | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (m migrateModel) View() string { | |
| 255 | + | if m.quitting { | |
| 256 | + | return "" | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | if m.err != nil { | |
| 260 | + | return m.renderer.RenderError(m.err) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | if m.state == migrateStateDone { | |
| 264 | + | return m.result | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | // Show confirmation dialog | |
| 268 | + | return m.confirm.View() | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func (m migrateModel) runMigration() tea.Cmd { | |
| 272 | + | return func() tea.Msg { | |
| 273 | + | ctx := context.Background() | |
| 274 | + | result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{}) | |
| 275 | + | return migrateResultMsg{output: result, err: err} | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog. | |
| 280 | + | func runMigrateWithConfirmation( | |
| 281 | + | ctx context.Context, | |
| 282 | + | uc *usecase.MigrateConfigUseCase, | |
| 283 | + | renderer *styles.ConfigRenderer, | |
| 284 | + | theme *styles.Theme, | |
| 285 | + | missingKeys []port.KeyInfo, | |
| 286 | + | ) error { | |
| 287 | + | // Suppress unused ctx warning - kept for consistency with other run functions | |
| 288 | + | _ = ctx | |
| 289 | + | ||
| 290 | + | m := newMigrateModel(renderer, theme, uc, missingKeys) | |
| 291 | + | p := tea.NewProgram(m) | |
| 292 | + | ||
| 293 | + | _, err := p.Run() | |
| 294 | + | if err != nil { | |
| 295 | + | return fmt.Errorf("migration failed: %w", err) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | return nil | |
| 299 | + | } | |
| @@ -0,0 +1,299 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | ||||
| 8 | + | "github.com/charmbracelet/bubbles/spinner" | |||
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 10 | + | "github.com/charmbracelet/lipgloss" | |||
| 11 | + | "github.com/spf13/cobra" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 14 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 15 | + | "github.com/bnema/dumber/internal/cli/styles" | |||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var configYes bool | |||
| 20 | + | ||||
| 21 | + | var configCmd = &cobra.Command{ | |||
| 22 | + | Use: "config", | |||
| 23 | + | Short: "Manage configuration", | |||
| 24 | + | Long: `View configuration status and migrate to add new default settings.`, | |||
| 25 | + | RunE: runConfigStatus, | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | var configMigrateCmd = &cobra.Command{ | |||
| 29 | + | Use: "migrate", | |||
| 30 | + | Short: "Add missing default settings to config file", | |||
| 31 | + | Long: `Compares your config file with available defaults and adds any missing settings. | |||
| 32 | + | ||||
| 33 | + | Existing settings are never modified - only missing keys are added with default values.`, | |||
| 34 | + | RunE: runConfigMigrate, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func init() { | |||
| 38 | + | rootCmd.AddCommand(configCmd) | |||
| 39 | + | configCmd.AddCommand(configMigrateCmd) | |||
| 40 | + | configMigrateCmd.Flags().BoolVarP(&configYes, "yes", "y", false, "skip confirmation prompt") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // runConfigStatus shows config file path and migration status. | |||
| 44 | + | func runConfigStatus(_ *cobra.Command, _ []string) error { | |||
| 45 | + | app := GetApp() | |||
| 46 | + | if app == nil { | |||
| 47 | + | return fmt.Errorf("app not initialized") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | renderer := styles.NewConfigRenderer(app.Theme) | |||
| 51 | + | migrator := config.NewMigrator() | |||
| 52 | + | uc := usecase.NewMigrateConfigUseCase(migrator) | |||
| 53 | + | ||||
| 54 | + | // Get config file path | |||
| 55 | + | configFile, err := config.GetConfigFile() | |||
| 56 | + | if err != nil { | |||
| 57 | + | fmt.Println(renderer.RenderError(err)) | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Check if config file exists | |||
| 62 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |||
| 63 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // Check for migration | |||
| 68 | + | ctx := context.Background() | |||
| 69 | + | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |||
| 70 | + | if err != nil { | |||
| 71 | + | fmt.Println(renderer.RenderError(err)) | |||
| 72 | + | return nil | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | if !result.NeedsMigration { | |||
| 76 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Show status with missing count | |||
| 81 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |||
| 82 | + | fmt.Println(renderer.RenderMigrateHint()) | |||
| 83 | + | ||||
| 84 | + | return nil | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // runConfigMigrate runs the migration with optional confirmation. | |||
| 88 | + | func runConfigMigrate(_ *cobra.Command, _ []string) error { | |||
| 89 | + | app := GetApp() | |||
| 90 | + | if app == nil { | |||
| 91 | + | return fmt.Errorf("app not initialized") | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | renderer := styles.NewConfigRenderer(app.Theme) | |||
| 95 | + | migrator := config.NewMigrator() | |||
| 96 | + | uc := usecase.NewMigrateConfigUseCase(migrator) | |||
| 97 | + | ||||
| 98 | + | // Get config file path | |||
| 99 | + | configFile, err := config.GetConfigFile() | |||
| 100 | + | if err != nil { | |||
| 101 | + | fmt.Println(renderer.RenderError(err)) | |||
| 102 | + | return nil | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Check if config file exists | |||
| 106 | + | if _, statErr := os.Stat(configFile); os.IsNotExist(statErr) { | |||
| 107 | + | fmt.Println(renderer.RenderNoConfigFile(configFile)) | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // Check for migration | |||
| 112 | + | ctx := context.Background() | |||
| 113 | + | result, err := uc.Check(ctx, usecase.CheckConfigMigrationInput{}) | |||
| 114 | + | if err != nil { | |||
| 115 | + | fmt.Println(renderer.RenderError(err)) | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | if !result.NeedsMigration { | |||
| 120 | + | fmt.Println(renderer.RenderUpToDate(configFile)) | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Show config info and missing keys | |||
| 125 | + | fmt.Println(renderer.RenderConfigInfo(configFile, len(result.MissingKeys))) | |||
| 126 | + | fmt.Println(renderer.RenderMissingKeys(result.MissingKeys)) | |||
| 127 | + | ||||
| 128 | + | // If --yes flag, proceed without confirmation | |||
| 129 | + | if configYes { | |||
| 130 | + | return executeMigration(ctx, uc, renderer) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Show confirmation dialog | |||
| 134 | + | return runMigrateWithConfirmation(ctx, uc, renderer, app.Theme, result.MissingKeys) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // executeMigration performs the actual migration. | |||
| 138 | + | func executeMigration(ctx context.Context, uc *usecase.MigrateConfigUseCase, renderer *styles.ConfigRenderer) error { | |||
| 139 | + | result, err := uc.Execute(ctx, usecase.MigrateConfigInput{}) | |||
| 140 | + | if err != nil { | |||
| 141 | + | fmt.Println(renderer.RenderError(err)) | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | if len(result.AddedKeys) > 0 { | |||
| 146 | + | fmt.Println(renderer.RenderMigrationSuccess(len(result.AddedKeys), result.ConfigFile)) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | return nil | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // migrateState represents the current state of the migrate confirmation. | |||
| 153 | + | type migrateState int | |||
| 154 | + | ||||
| 155 | + | const ( | |||
| 156 | + | migrateStateConfirm migrateState = iota | |||
| 157 | + | migrateStateDone | |||
| 158 | + | ) | |||
| 159 | + | ||||
| 160 | + | // migrateModel is the bubbletea model for the migrate confirmation. | |||
| 161 | + | type migrateModel struct { | |||
| 162 | + | spinner spinner.Model | |||
| 163 | + | renderer *styles.ConfigRenderer | |||
| 164 | + | confirm styles.ConfirmModel | |||
| 165 | + | state migrateState | |||
| 166 | + | uc *usecase.MigrateConfigUseCase | |||
| 167 | + | missingKeys []port.KeyInfo | |||
| 168 | + | ||||
| 169 | + | result string | |||
| 170 | + | err error | |||
| 171 | + | quitting bool | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | // migrateResultMsg is sent when the migration completes. | |||
| 175 | + | type migrateResultMsg struct { | |||
| 176 | + | output *usecase.MigrateConfigOutput | |||
| 177 | + | err error | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func newMigrateModel( | |||
| 181 | + | renderer *styles.ConfigRenderer, | |||
| 182 | + | theme *styles.Theme, | |||
| 183 | + | uc *usecase.MigrateConfigUseCase, | |||
| 184 | + | missingKeys []port.KeyInfo, | |||
| 185 | + | ) migrateModel { | |||
| 186 | + | s := spinner.New() | |||
| 187 | + | s.Spinner = spinner.Dot | |||
| 188 | + | s.Style = lipgloss.NewStyle().Foreground(theme.Accent) | |||
| 189 | + | ||||
| 190 | + | confirm := styles.NewConfirm(theme, "Add these settings with default values?") | |||
| 191 | + | ||||
| 192 | + | return migrateModel{ | |||
| 193 | + | spinner: s, | |||
| 194 | + | renderer: renderer, | |||
| 195 | + | confirm: confirm, | |||
| 196 | + | state: migrateStateConfirm, | |||
| 197 | + | uc: uc, | |||
| 198 | + | missingKeys: missingKeys, | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | ||||
| 202 | + | func (m migrateModel) Init() tea.Cmd { | |||
| 203 | + | return m.spinner.Tick | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | func (m migrateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 207 | + | switch msg := msg.(type) { | |||
| 208 | + | case tea.KeyMsg: | |||
| 209 | + | switch msg.String() { | |||
| 210 | + | case "ctrl+c": | |||
| 211 | + | m.quitting = true | |||
| 212 | + | return m, tea.Quit | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | case spinner.TickMsg: | |||
| 216 | + | var cmd tea.Cmd | |||
| 217 | + | m.spinner, cmd = m.spinner.Update(msg) | |||
| 218 | + | return m, cmd | |||
| 219 | + | ||||
| 220 | + | case migrateResultMsg: | |||
| 221 | + | m.state = migrateStateDone | |||
| 222 | + | if msg.err != nil { | |||
| 223 | + | m.err = msg.err | |||
| 224 | + | return m, tea.Quit | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | if len(msg.output.AddedKeys) > 0 { | |||
| 228 | + | m.result = m.renderer.RenderMigrationSuccess(len(msg.output.AddedKeys), msg.output.ConfigFile) | |||
| 229 | + | } | |||
| 230 | + | return m, tea.Quit | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // Handle confirm dialog | |||
| 234 | + | if m.state == migrateStateConfirm { | |||
| 235 | + | var cmd tea.Cmd | |||
| 236 | + | m.confirm, cmd = m.confirm.Update(msg) | |||
| 237 | + | ||||
| 238 | + | if m.confirm.Done() { | |||
| 239 | + | if m.confirm.Result() { | |||
| 240 | + | // User confirmed, run migration | |||
| 241 | + | return m, m.runMigration() | |||
| 242 | + | } | |||
| 243 | + | // User canceled | |||
| 244 | + | m.quitting = true | |||
| 245 | + | return m, tea.Quit | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | return m, cmd | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | return m, nil | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (m migrateModel) View() string { | |||
| 255 | + | if m.quitting { | |||
| 256 | + | return "" | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | if m.err != nil { | |||
| 260 | + | return m.renderer.RenderError(m.err) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | if m.state == migrateStateDone { | |||
| 264 | + | return m.result | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // Show confirmation dialog | |||
| 268 | + | return m.confirm.View() | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func (m migrateModel) runMigration() tea.Cmd { | |||
| 272 | + | return func() tea.Msg { | |||
| 273 | + | ctx := context.Background() | |||
| 274 | + | result, err := m.uc.Execute(ctx, usecase.MigrateConfigInput{}) | |||
| 275 | + | return migrateResultMsg{output: result, err: err} | |||
| 276 | + | } | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // runMigrateWithConfirmation runs the migrate with an interactive confirmation dialog. | |||
| 280 | + | func runMigrateWithConfirmation( | |||
| 281 | + | ctx context.Context, | |||
| 282 | + | uc *usecase.MigrateConfigUseCase, | |||
| 283 | + | renderer *styles.ConfigRenderer, | |||
| 284 | + | theme *styles.Theme, | |||
| 285 | + | missingKeys []port.KeyInfo, | |||
| 286 | + | ) error { | |||
| 287 | + | // Suppress unused ctx warning - kept for consistency with other run functions | |||
| 288 | + | _ = ctx | |||
| 289 | + | ||||
| 290 | + | m := newMigrateModel(renderer, theme, uc, missingKeys) | |||
| 291 | + | p := tea.NewProgram(m) | |||
| 292 | + | ||||
| 293 | + | _, err := p.Run() | |||
| 294 | + | if err != nil { | |||
| 295 | + | return fmt.Errorf("migration failed: %w", err) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | return nil | |||
| 299 | + | } | |||
A internal/cli/styles/config.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package styles | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/charmbracelet/lipgloss" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // ConfigRenderer renders config status messages with styled output. | |
| 13 | + | type ConfigRenderer struct { | |
| 14 | + | theme *Theme | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewConfigRenderer creates a new config renderer with the given theme. | |
| 18 | + | func NewConfigRenderer(theme *Theme) *ConfigRenderer { | |
| 19 | + | return &ConfigRenderer{theme: theme} | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // RenderConfigInfo renders the config file info with status. | |
| 23 | + | func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string { | |
| 24 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 25 | + | pathStyle := r.theme.Subtle | |
| 26 | + | ||
| 27 | + | var status string | |
| 28 | + | if missingCount > 0 { | |
| 29 | + | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |
| 30 | + | status = fmt.Sprintf("\n %s %s new settings available", | |
| 31 | + | iconStyle.Render(IconInfo), | |
| 32 | + | countStyle.Render(fmt.Sprintf("%d", missingCount)), | |
| 33 | + | ) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | return fmt.Sprintf( | |
| 37 | + | "\n %s Config %s%s\n", | |
| 38 | + | iconStyle.Render(IconConfig), | |
| 39 | + | pathStyle.Render(path), | |
| 40 | + | status, | |
| 41 | + | ) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // RenderMissingKeys renders the list of missing keys with their types and default values. | |
| 45 | + | func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |
| 46 | + | if len(keys) == 0 { | |
| 47 | + | return "" | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 51 | + | keyStyle := r.theme.Highlight | |
| 52 | + | typeStyle := r.theme.Subtle | |
| 53 | + | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | |
| 54 | + | ||
| 55 | + | var sb strings.Builder | |
| 56 | + | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | |
| 57 | + | ||
| 58 | + | for _, key := range keys { | |
| 59 | + | sb.WriteString(fmt.Sprintf( | |
| 60 | + | " %s %s\n Type: %s | Default: %s\n", | |
| 61 | + | iconStyle.Render(IconCursor), | |
| 62 | + | keyStyle.Render(key.Key), | |
| 63 | + | typeStyle.Render(key.Type), | |
| 64 | + | valueStyle.Render(key.DefaultValue), | |
| 65 | + | )) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | return sb.String() | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // RenderMigrationSuccess renders the success message after migration. | |
| 72 | + | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |
| 73 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 74 | + | countStyle := r.theme.Highlight | |
| 75 | + | pathStyle := r.theme.Subtle | |
| 76 | + | ||
| 77 | + | // Get just the filename from the path | |
| 78 | + | parts := strings.Split(path, "/") | |
| 79 | + | filename := parts[len(parts)-1] | |
| 80 | + | ||
| 81 | + | return fmt.Sprintf( | |
| 82 | + | "\n %s Added %s new settings to %s\n", | |
| 83 | + | iconStyle.Render(IconCheck), | |
| 84 | + | countStyle.Render(fmt.Sprintf("%d", count)), | |
| 85 | + | pathStyle.Render(filename), | |
| 86 | + | ) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // RenderUpToDate renders the "config is up to date" message. | |
| 90 | + | func (r *ConfigRenderer) RenderUpToDate(path string) string { | |
| 91 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |
| 92 | + | pathStyle := r.theme.Subtle | |
| 93 | + | ||
| 94 | + | return fmt.Sprintf( | |
| 95 | + | "\n %s Config %s\n %s Config is up to date\n", | |
| 96 | + | iconStyle.Render(IconConfig), | |
| 97 | + | pathStyle.Render(path), | |
| 98 | + | iconStyle.Render(IconCheck), | |
| 99 | + | ) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // RenderError renders an error message. | |
| 103 | + | func (r *ConfigRenderer) RenderError(err error) string { | |
| 104 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |
| 105 | + | ||
| 106 | + | return fmt.Sprintf( | |
| 107 | + | "\n %s Config error: %v\n", | |
| 108 | + | iconStyle.Render(IconX), | |
| 109 | + | err, | |
| 110 | + | ) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // RenderChecking renders the "checking config..." message with spinner. | |
| 114 | + | func (*ConfigRenderer) RenderChecking(spinner string) string { | |
| 115 | + | return fmt.Sprintf("\n %s Checking config...\n", spinner) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // RenderMigrateHint renders a hint to run the migrate command. | |
| 119 | + | func (r *ConfigRenderer) RenderMigrateHint() string { | |
| 120 | + | hintStyle := r.theme.Subtle | |
| 121 | + | ||
| 122 | + | return fmt.Sprintf( | |
| 123 | + | "\n %s\n", | |
| 124 | + | hintStyle.Render("Run 'dumber config migrate' to add missing defaults."), | |
| 125 | + | ) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // RenderNoConfigFile renders message when config file doesn't exist yet. | |
| 129 | + | func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |
| 130 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |
| 131 | + | pathStyle := r.theme.Subtle | |
| 132 | + | hintStyle := r.theme.Subtle | |
| 133 | + | ||
| 134 | + | return fmt.Sprintf( | |
| 135 | + | "\n %s Config %s\n %s\n", | |
| 136 | + | iconStyle.Render(IconConfig), | |
| 137 | + | pathStyle.Render(path), | |
| 138 | + | hintStyle.Render("Config file will be created on first run with all defaults."), | |
| 139 | + | ) | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package styles | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/charmbracelet/lipgloss" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // ConfigRenderer renders config status messages with styled output. | |||
| 13 | + | type ConfigRenderer struct { | |||
| 14 | + | theme *Theme | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | // NewConfigRenderer creates a new config renderer with the given theme. | |||
| 18 | + | func NewConfigRenderer(theme *Theme) *ConfigRenderer { | |||
| 19 | + | return &ConfigRenderer{theme: theme} | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RenderConfigInfo renders the config file info with status. | |||
| 23 | + | func (r *ConfigRenderer) RenderConfigInfo(path string, missingCount int) string { | |||
| 24 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 25 | + | pathStyle := r.theme.Subtle | |||
| 26 | + | ||||
| 27 | + | var status string | |||
| 28 | + | if missingCount > 0 { | |||
| 29 | + | countStyle := lipgloss.NewStyle().Foreground(r.theme.Warning) | |||
| 30 | + | status = fmt.Sprintf("\n %s %s new settings available", | |||
| 31 | + | iconStyle.Render(IconInfo), | |||
| 32 | + | countStyle.Render(fmt.Sprintf("%d", missingCount)), | |||
| 33 | + | ) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | return fmt.Sprintf( | |||
| 37 | + | "\n %s Config %s%s\n", | |||
| 38 | + | iconStyle.Render(IconConfig), | |||
| 39 | + | pathStyle.Render(path), | |||
| 40 | + | status, | |||
| 41 | + | ) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // RenderMissingKeys renders the list of missing keys with their types and default values. | |||
| 45 | + | func (r *ConfigRenderer) RenderMissingKeys(keys []port.KeyInfo) string { | |||
| 46 | + | if len(keys) == 0 { | |||
| 47 | + | return "" | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 51 | + | keyStyle := r.theme.Highlight | |||
| 52 | + | typeStyle := r.theme.Subtle | |||
| 53 | + | valueStyle := lipgloss.NewStyle().Foreground(r.theme.Text) | |||
| 54 | + | ||||
| 55 | + | var sb strings.Builder | |||
| 56 | + | sb.WriteString(fmt.Sprintf("\n Missing settings (%d):\n", len(keys))) | |||
| 57 | + | ||||
| 58 | + | for _, key := range keys { | |||
| 59 | + | sb.WriteString(fmt.Sprintf( | |||
| 60 | + | " %s %s\n Type: %s | Default: %s\n", | |||
| 61 | + | iconStyle.Render(IconCursor), | |||
| 62 | + | keyStyle.Render(key.Key), | |||
| 63 | + | typeStyle.Render(key.Type), | |||
| 64 | + | valueStyle.Render(key.DefaultValue), | |||
| 65 | + | )) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | return sb.String() | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // RenderMigrationSuccess renders the success message after migration. | |||
| 72 | + | func (r *ConfigRenderer) RenderMigrationSuccess(count int, path string) string { | |||
| 73 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 74 | + | countStyle := r.theme.Highlight | |||
| 75 | + | pathStyle := r.theme.Subtle | |||
| 76 | + | ||||
| 77 | + | // Get just the filename from the path | |||
| 78 | + | parts := strings.Split(path, "/") | |||
| 79 | + | filename := parts[len(parts)-1] | |||
| 80 | + | ||||
| 81 | + | return fmt.Sprintf( | |||
| 82 | + | "\n %s Added %s new settings to %s\n", | |||
| 83 | + | iconStyle.Render(IconCheck), | |||
| 84 | + | countStyle.Render(fmt.Sprintf("%d", count)), | |||
| 85 | + | pathStyle.Render(filename), | |||
| 86 | + | ) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // RenderUpToDate renders the "config is up to date" message. | |||
| 90 | + | func (r *ConfigRenderer) RenderUpToDate(path string) string { | |||
| 91 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success) | |||
| 92 | + | pathStyle := r.theme.Subtle | |||
| 93 | + | ||||
| 94 | + | return fmt.Sprintf( | |||
| 95 | + | "\n %s Config %s\n %s Config is up to date\n", | |||
| 96 | + | iconStyle.Render(IconConfig), | |||
| 97 | + | pathStyle.Render(path), | |||
| 98 | + | iconStyle.Render(IconCheck), | |||
| 99 | + | ) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // RenderError renders an error message. | |||
| 103 | + | func (r *ConfigRenderer) RenderError(err error) string { | |||
| 104 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Error) | |||
| 105 | + | ||||
| 106 | + | return fmt.Sprintf( | |||
| 107 | + | "\n %s Config error: %v\n", | |||
| 108 | + | iconStyle.Render(IconX), | |||
| 109 | + | err, | |||
| 110 | + | ) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | // RenderChecking renders the "checking config..." message with spinner. | |||
| 114 | + | func (*ConfigRenderer) RenderChecking(spinner string) string { | |||
| 115 | + | return fmt.Sprintf("\n %s Checking config...\n", spinner) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // RenderMigrateHint renders a hint to run the migrate command. | |||
| 119 | + | func (r *ConfigRenderer) RenderMigrateHint() string { | |||
| 120 | + | hintStyle := r.theme.Subtle | |||
| 121 | + | ||||
| 122 | + | return fmt.Sprintf( | |||
| 123 | + | "\n %s\n", | |||
| 124 | + | hintStyle.Render("Run 'dumber config migrate' to add missing defaults."), | |||
| 125 | + | ) | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // RenderNoConfigFile renders message when config file doesn't exist yet. | |||
| 129 | + | func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |||
| 130 | + | iconStyle := lipgloss.NewStyle().Foreground(r.theme.Accent) | |||
| 131 | + | pathStyle := r.theme.Subtle | |||
| 132 | + | hintStyle := r.theme.Subtle | |||
| 133 | + | ||||
| 134 | + | return fmt.Sprintf( | |||
| 135 | + | "\n %s Config %s\n %s\n", | |||
| 136 | + | iconStyle.Render(IconConfig), | |||
| 137 | + | pathStyle.Render(path), | |||
| 138 | + | hintStyle.Render("Config file will be created on first run with all defaults."), | |||
| 139 | + | ) | |||
| 140 | + | } | |||