detached 66c4cea

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
55 import (
66 "context"
77 "fmt"
8+ "os"
9+ "os/signal"
10+ "syscall"
811
912 "github.com/spf13/cobra"
1013
@@ -88,6 +91,18 @@ Commands are organized by where they run:
8891 deployCmd.GroupID = groupManage
8992 rootCmd.AddCommand(deployCmd)
9093
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+
91106 reloadCmd := newReloadCmd()
92107 reloadCmd.GroupID = groupManage
93108 rootCmd.AddCommand(reloadCmd)
@@ -232,7 +247,8 @@ func runLogs(logsConfigPath, logDomain string, follow bool, lines int) error {
232247
233248 // runLogsRemote fetches logs from a remote Gordon instance.
234249 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()
236252
237253 if follow {
238254 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 = groupManage91 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 = groupManage107 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close