feat(cli): register restart, push, and rollback commands with selector UI
Add restart command for restarting containers with optional attachment restart. Register push and rollback commands in the manage group. Add interactive selector component for tag selection in rollback. Use signal-aware context for remote log streaming to handle Ctrl+C cleanly.
- commit
- 66c4cead3e1782d74631c8236de6071cc47fdf2a
- author
- brice · · 2026-02-01 17:19 UTC
- committer
- brice · · 2026-02-02 18:05 UTC
- parent
- dc89b46
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +179 -1
A internal/adapters/in/cli/restart.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/spf13/cobra" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func newRestartCmd() *cobra.Command { | |
| 12 | + | var withAttachments bool | |
| 13 | + | ||
| 14 | + | cmd := &cobra.Command{ | |
| 15 | + | Use: "restart <domain>", | |
| 16 | + | Short: "Restart a running container", | |
| 17 | + | Long: `Restarts the container for the specified route domain. | |
| 18 | + | Useful after changing environment variables with 'gordon secrets set'. | |
| 19 | + | ||
| 20 | + | Use --with-attachments to also restart attached services (databases, caches). | |
| 21 | + | ||
| 22 | + | Examples: | |
| 23 | + | gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |
| 24 | + | gordon restart myapp.example.com --with-attachments --remote ...`, | |
| 25 | + | Args: cobra.ExactArgs(1), | |
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 27 | + | ctx := cmd.Context() | |
| 28 | + | restartDomain := args[0] | |
| 29 | + | ||
| 30 | + | client, isRemote := GetRemoteClient() | |
| 31 | + | if !isRemote { | |
| 32 | + | fmt.Println(styles.RenderError("restart command requires --remote flag or GORDON_REMOTE env var")) | |
| 33 | + | return fmt.Errorf("restart requires remote mode") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | result, err := client.Restart(ctx, restartDomain, withAttachments) | |
| 37 | + | if err != nil { | |
| 38 | + | return fmt.Errorf("failed to restart: %w", err) | |
| 39 | + | } | |
| 40 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | |
| 41 | + | return nil | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)") | |
| 46 | + | ||
| 47 | + | return cmd | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/spf13/cobra" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func newRestartCmd() *cobra.Command { | |||
| 12 | + | var withAttachments bool | |||
| 13 | + | ||||
| 14 | + | cmd := &cobra.Command{ | |||
| 15 | + | Use: "restart <domain>", | |||
| 16 | + | Short: "Restart a running container", | |||
| 17 | + | Long: `Restarts the container for the specified route domain. | |||
| 18 | + | Useful after changing environment variables with 'gordon secrets set'. | |||
| 19 | + | ||||
| 20 | + | Use --with-attachments to also restart attached services (databases, caches). | |||
| 21 | + | ||||
| 22 | + | Examples: | |||
| 23 | + | gordon restart myapp.example.com --remote https://gordon.mydomain.com --token $TOKEN | |||
| 24 | + | gordon restart myapp.example.com --with-attachments --remote ...`, | |||
| 25 | + | Args: cobra.ExactArgs(1), | |||
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 27 | + | ctx := cmd.Context() | |||
| 28 | + | restartDomain := args[0] | |||
| 29 | + | ||||
| 30 | + | client, isRemote := GetRemoteClient() | |||
| 31 | + | if !isRemote { | |||
| 32 | + | fmt.Println(styles.RenderError("restart command requires --remote flag or GORDON_REMOTE env var")) | |||
| 33 | + | return fmt.Errorf("restart requires remote mode") | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | result, err := client.Restart(ctx, restartDomain, withAttachments) | |||
| 37 | + | if err != nil { | |||
| 38 | + | return fmt.Errorf("failed to restart: %w", err) | |||
| 39 | + | } | |||
| 40 | + | fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain))) | |||
| 41 | + | return nil | |||
| 42 | + | }, | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | cmd.Flags().BoolVar(&withAttachments, "with-attachments", false, "Also restart attached services (databases, caches)") | |||
| 46 | + | ||||
| 47 | + | return cmd | |||
| 48 | + | } | |||
M internal/adapters/in/cli/root.go +17 -1
| @@ -5,6 +5,9 @@ package cli | |||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | 7 | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/signal" | |
| 10 | + | "syscall" | |
| 8 | 11 | ||
| 9 | 12 | "github.com/spf13/cobra" | |
| 10 | 13 | ||
| @@ -88,6 +91,18 @@ Commands are organized by where they run: | |||
| 88 | 91 | deployCmd.GroupID = groupManage | |
| 89 | 92 | rootCmd.AddCommand(deployCmd) | |
| 90 | 93 | ||
| 94 | + | restartCmd := newRestartCmd() | |
| 95 | + | restartCmd.GroupID = groupManage | |
| 96 | + | rootCmd.AddCommand(restartCmd) | |
| 97 | + | ||
| 98 | + | pushCmd := newPushCmd() | |
| 99 | + | pushCmd.GroupID = groupManage | |
| 100 | + | rootCmd.AddCommand(pushCmd) | |
| 101 | + | ||
| 102 | + | rollbackCmd := newRollbackCmd() | |
| 103 | + | rollbackCmd.GroupID = groupManage | |
| 104 | + | rootCmd.AddCommand(rollbackCmd) | |
| 105 | + | ||
| 91 | 106 | reloadCmd := newReloadCmd() | |
| 92 | 107 | reloadCmd.GroupID = groupManage | |
| 93 | 108 | rootCmd.AddCommand(reloadCmd) | |
| @@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error { | |||
| 232 | 247 | ||
| 233 | 248 | // runLogsRemote fetches logs from a remote Gordon instance. | |
| 234 | 249 | func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error { | |
| 235 | - | ctx := context.Background() | |
| 250 | + | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 251 | + | defer cancel() | |
| 236 | 252 | ||
| 237 | 253 | if follow { | |
| 238 | 254 | return streamLogsRemote(ctx, client, logDomain, lines) | |
| @@ -5,6 +5,9 @@ package cli | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "os" | |||
| 9 | + | "os/signal" | |||
| 10 | + | "syscall" | |||
| 8 | 11 | ||||
| 9 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| 10 | 13 | ||||
| @@ -88,6 +91,18 @@ Commands are organized by where they run: | |||||
| 88 | deployCmd.GroupID = groupManage | 91 | deployCmd.GroupID = groupManage | ||
| 89 | rootCmd.AddCommand(deployCmd) | 92 | rootCmd.AddCommand(deployCmd) | ||
| 90 | 93 | ||||
| 94 | + | restartCmd := newRestartCmd() | |||
| 95 | + | restartCmd.GroupID = groupManage | |||
| 96 | + | rootCmd.AddCommand(restartCmd) | |||
| 97 | + | ||||
| 98 | + | pushCmd := newPushCmd() | |||
| 99 | + | pushCmd.GroupID = groupManage | |||
| 100 | + | rootCmd.AddCommand(pushCmd) | |||
| 101 | + | ||||
| 102 | + | rollbackCmd := newRollbackCmd() | |||
| 103 | + | rollbackCmd.GroupID = groupManage | |||
| 104 | + | rootCmd.AddCommand(rollbackCmd) | |||
| 105 | + | ||||
| 91 | reloadCmd := newReloadCmd() | 106 | reloadCmd := newReloadCmd() | ||
| 92 | reloadCmd.GroupID = groupManage | 107 | reloadCmd.GroupID = groupManage | ||
| 93 | rootCmd.AddCommand(reloadCmd) | 108 | rootCmd.AddCommand(reloadCmd) | ||
| @@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error { | |||||
| 232 | 247 | ||||
| 233 | // runLogsRemote fetches logs from a remote Gordon instance. | 248 | // runLogsRemote fetches logs from a remote Gordon instance. | ||
| 234 | func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error { | 249 | func runLogsRemote(client *remote.Client, logDomain string, follow bool, lines int) error { | ||
| 235 | - | ctx := context.Background() | 250 | + | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) |
| 251 | + | defer cancel() | |||
| 236 | 252 | ||||
| 237 | if follow { | 253 | if follow { | ||
| 238 | return streamLogsRemote(ctx, client, logDomain, lines) | 254 | return streamLogsRemote(ctx, client, logDomain, lines) | ||
A internal/adapters/in/cli/ui/components/selector.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package components | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 8 | + | ||
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |
| 10 | + | "github.com/charmbracelet/lipgloss" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // SelectorModel is a list selection dialog. | |
| 14 | + | type SelectorModel struct { | |
| 15 | + | title string | |
| 16 | + | items []string | |
| 17 | + | current string // highlighted item (e.g., current tag) | |
| 18 | + | cursor int | |
| 19 | + | selected string | |
| 20 | + | cancelled bool | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // NewSelector creates a new selector dialog. | |
| 24 | + | func NewSelector(title string, items []string, current string) SelectorModel { | |
| 25 | + | return SelectorModel{ | |
| 26 | + | title: title, | |
| 27 | + | items: items, | |
| 28 | + | current: current, | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (m SelectorModel) Init() tea.Cmd { return nil } | |
| 33 | + | ||
| 34 | + | func (m SelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 35 | + | switch msg := msg.(type) { | |
| 36 | + | case tea.KeyMsg: | |
| 37 | + | if len(m.items) == 0 { | |
| 38 | + | switch msg.String() { | |
| 39 | + | case "esc", "ctrl+c", "q": | |
| 40 | + | m.cancelled = true | |
| 41 | + | return m, tea.Quit | |
| 42 | + | } | |
| 43 | + | return m, nil | |
| 44 | + | } | |
| 45 | + | switch msg.String() { | |
| 46 | + | case "up", "k": | |
| 47 | + | if m.cursor > 0 { | |
| 48 | + | m.cursor-- | |
| 49 | + | } | |
| 50 | + | case "down", "j": | |
| 51 | + | if m.cursor < len(m.items)-1 { | |
| 52 | + | m.cursor++ | |
| 53 | + | } | |
| 54 | + | case "enter": | |
| 55 | + | m.selected = m.items[m.cursor] | |
| 56 | + | return m, tea.Quit | |
| 57 | + | case "esc", "ctrl+c", "q": | |
| 58 | + | m.cancelled = true | |
| 59 | + | return m, tea.Quit | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return m, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (m SelectorModel) View() string { | |
| 66 | + | var b strings.Builder | |
| 67 | + | ||
| 68 | + | b.WriteString(styles.Theme.Bold.Render(m.title)) | |
| 69 | + | b.WriteString("\n\n") | |
| 70 | + | ||
| 71 | + | for i, item := range m.items { | |
| 72 | + | cursor := " " | |
| 73 | + | if i == m.cursor { | |
| 74 | + | cursor = "> " | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | line := item | |
| 78 | + | if item == m.current { | |
| 79 | + | line = item + " " + lipgloss.NewStyle().Foreground(styles.ColorPrimary).Render("(current)") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | if i == m.cursor { | |
| 83 | + | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | |
| 84 | + | } else { | |
| 85 | + | b.WriteString(fmt.Sprintf("%s%s", cursor, line)) | |
| 86 | + | } | |
| 87 | + | b.WriteString("\n") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | b.WriteString("\n") | |
| 91 | + | b.WriteString(styles.RenderKeyHelp("↑/↓", "navigate") + " " + | |
| 92 | + | styles.RenderKeyHelp("enter", "select") + " " + | |
| 93 | + | styles.RenderKeyHelp("esc", "cancel")) | |
| 94 | + | ||
| 95 | + | return b.String() | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (m SelectorModel) Selected() string { return m.selected } | |
| 99 | + | func (m SelectorModel) Cancelled() bool { return m.cancelled } | |
| 100 | + | ||
| 101 | + | // RunSelector runs a selector dialog and returns the selected item. | |
| 102 | + | func RunSelector(title string, items []string, current string) (string, error) { | |
| 103 | + | m := NewSelector(title, items, current) | |
| 104 | + | p := tea.NewProgram(m) | |
| 105 | + | finalModel, err := p.Run() | |
| 106 | + | if err != nil { | |
| 107 | + | return "", fmt.Errorf("error running selector: %w", err) | |
| 108 | + | } | |
| 109 | + | result := finalModel.(SelectorModel) | |
| 110 | + | if result.Cancelled() { | |
| 111 | + | return "", nil | |
| 112 | + | } | |
| 113 | + | return result.Selected(), nil | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package components | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |||
| 8 | + | ||||
| 9 | + | tea "github.com/charmbracelet/bubbletea" | |||
| 10 | + | "github.com/charmbracelet/lipgloss" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // SelectorModel is a list selection dialog. | |||
| 14 | + | type SelectorModel struct { | |||
| 15 | + | title string | |||
| 16 | + | items []string | |||
| 17 | + | current string // highlighted item (e.g., current tag) | |||
| 18 | + | cursor int | |||
| 19 | + | selected string | |||
| 20 | + | cancelled bool | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // NewSelector creates a new selector dialog. | |||
| 24 | + | func NewSelector(title string, items []string, current string) SelectorModel { | |||
| 25 | + | return SelectorModel{ | |||
| 26 | + | title: title, | |||
| 27 | + | items: items, | |||
| 28 | + | current: current, | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (m SelectorModel) Init() tea.Cmd { return nil } | |||
| 33 | + | ||||
| 34 | + | func (m SelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |||
| 35 | + | switch msg := msg.(type) { | |||
| 36 | + | case tea.KeyMsg: | |||
| 37 | + | if len(m.items) == 0 { | |||
| 38 | + | switch msg.String() { | |||
| 39 | + | case "esc", "ctrl+c", "q": | |||
| 40 | + | m.cancelled = true | |||
| 41 | + | return m, tea.Quit | |||
| 42 | + | } | |||
| 43 | + | return m, nil | |||
| 44 | + | } | |||
| 45 | + | switch msg.String() { | |||
| 46 | + | case "up", "k": | |||
| 47 | + | if m.cursor > 0 { | |||
| 48 | + | m.cursor-- | |||
| 49 | + | } | |||
| 50 | + | case "down", "j": | |||
| 51 | + | if m.cursor < len(m.items)-1 { | |||
| 52 | + | m.cursor++ | |||
| 53 | + | } | |||
| 54 | + | case "enter": | |||
| 55 | + | m.selected = m.items[m.cursor] | |||
| 56 | + | return m, tea.Quit | |||
| 57 | + | case "esc", "ctrl+c", "q": | |||
| 58 | + | m.cancelled = true | |||
| 59 | + | return m, tea.Quit | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | return m, nil | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (m SelectorModel) View() string { | |||
| 66 | + | var b strings.Builder | |||
| 67 | + | ||||
| 68 | + | b.WriteString(styles.Theme.Bold.Render(m.title)) | |||
| 69 | + | b.WriteString("\n\n") | |||
| 70 | + | ||||
| 71 | + | for i, item := range m.items { | |||
| 72 | + | cursor := " " | |||
| 73 | + | if i == m.cursor { | |||
| 74 | + | cursor = "> " | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | line := item | |||
| 78 | + | if item == m.current { | |||
| 79 | + | line = item + " " + lipgloss.NewStyle().Foreground(styles.ColorPrimary).Render("(current)") | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | if i == m.cursor { | |||
| 83 | + | b.WriteString(styles.Theme.Highlight.Render(cursor + line)) | |||
| 84 | + | } else { | |||
| 85 | + | b.WriteString(fmt.Sprintf("%s%s", cursor, line)) | |||
| 86 | + | } | |||
| 87 | + | b.WriteString("\n") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | b.WriteString("\n") | |||
| 91 | + | b.WriteString(styles.RenderKeyHelp("↑/↓", "navigate") + " " + | |||
| 92 | + | styles.RenderKeyHelp("enter", "select") + " " + | |||
| 93 | + | styles.RenderKeyHelp("esc", "cancel")) | |||
| 94 | + | ||||
| 95 | + | return b.String() | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (m SelectorModel) Selected() string { return m.selected } | |||
| 99 | + | func (m SelectorModel) Cancelled() bool { return m.cancelled } | |||
| 100 | + | ||||
| 101 | + | // RunSelector runs a selector dialog and returns the selected item. | |||
| 102 | + | func RunSelector(title string, items []string, current string) (string, error) { | |||
| 103 | + | m := NewSelector(title, items, current) | |||
| 104 | + | p := tea.NewProgram(m) | |||
| 105 | + | finalModel, err := p.Run() | |||
| 106 | + | if err != nil { | |||
| 107 | + | return "", fmt.Errorf("error running selector: %w", err) | |||
| 108 | + | } | |||
| 109 | + | result := finalModel.(SelectorModel) | |||
| 110 | + | if result.Cancelled() { | |||
| 111 | + | return "", nil | |||
| 112 | + | } | |||
| 113 | + | return result.Selected(), nil | |||
| 114 | + | } | |||