detached 382b937

feat(cli): add serve command with remote management

commit
382b9376f6fd7129520832ef7ab5476fe78fea25
author
bnema · · 2026-01-16 20:49 UTC
parent
e4beced
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +2857 -20
M Dockerfile +1 -1
@@ -63,7 +63,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
6363 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
6464
6565 # Default command
66-CMD ["./gordon", "start"]
66+CMD ["./gordon", "serve"]
6767
6868 # Metadata
6969 LABEL maintainer="bnemam"
@@ -63,7 +63,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
63 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 163 CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1
64 64
65 # Default command65 # Default command
66-CMD ["./gordon", "start"]66+CMD ["./gordon", "serve"]
67 67
68 # Metadata68 # Metadata
69 LABEL maintainer="bnemam"69 LABEL maintainer="bnemam"
A internal/adapters/in/cli/remote/client.go +295 -0
@@ -0,0 +1,295 @@
1+// Package remote provides an HTTP client for connecting to remote Gordon instances.
2+package remote
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "gordon/internal/domain"
15+)
16+
17+// Client is an HTTP client for the Gordon admin API.
18+type Client struct {
19+ baseURL string
20+ token string
21+ httpClient *http.Client
22+}
23+
24+// ClientOption configures the Client.
25+type ClientOption func(*Client)
26+
27+// NewClient creates a new remote Gordon client.
28+func NewClient(baseURL string, opts ...ClientOption) *Client {
29+ // Normalize base URL
30+ baseURL = strings.TrimSuffix(baseURL, "/")
31+
32+ c := &Client{
33+ baseURL: baseURL,
34+ httpClient: &http.Client{
35+ Timeout: 30 * time.Second,
36+ },
37+ }
38+
39+ for _, opt := range opts {
40+ opt(c)
41+ }
42+
43+ return c
44+}
45+
46+// WithToken sets the authentication token.
47+func WithToken(token string) ClientOption {
48+ return func(c *Client) {
49+ c.token = token
50+ }
51+}
52+
53+// WithHTTPClient sets a custom HTTP client.
54+func WithHTTPClient(client *http.Client) ClientOption {
55+ return func(c *Client) {
56+ c.httpClient = client
57+ }
58+}
59+
60+// WithTimeout sets the HTTP client timeout.
61+func WithTimeout(timeout time.Duration) ClientOption {
62+ return func(c *Client) {
63+ c.httpClient.Timeout = timeout
64+ }
65+}
66+
67+// request performs an HTTP request to the admin API.
68+func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
69+ url := c.baseURL + "/admin" + path
70+
71+ var bodyReader io.Reader
72+ if body != nil {
73+ jsonBody, err := json.Marshal(body)
74+ if err != nil {
75+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
76+ }
77+ bodyReader = bytes.NewReader(jsonBody)
78+ }
79+
80+ req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
81+ if err != nil {
82+ return nil, fmt.Errorf("failed to create request: %w", err)
83+ }
84+
85+ req.Header.Set("Content-Type", "application/json")
86+ req.Header.Set("Accept", "application/json")
87+
88+ if c.token != "" {
89+ req.Header.Set("Authorization", "Bearer "+c.token)
90+ }
91+
92+ return c.httpClient.Do(req)
93+}
94+
95+// parseResponse parses a JSON response into the given target.
96+func parseResponse(resp *http.Response, target any) error {
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode >= 400 {
100+ body, _ := io.ReadAll(resp.Body)
101+ var errResp struct {
102+ Error string `json:"error"`
103+ }
104+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
105+ return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
106+ }
107+ return fmt.Errorf("%s: %s", resp.Status, string(body))
108+ }
109+
110+ if target != nil {
111+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
112+ return fmt.Errorf("failed to decode response: %w", err)
113+ }
114+ }
115+
116+ return nil
117+}
118+
119+// Routes API
120+
121+// ListRoutes returns all configured routes.
122+func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
123+ resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
124+ if err != nil {
125+ return nil, err
126+ }
127+
128+ var result struct {
129+ Routes []domain.Route `json:"routes"`
130+ }
131+ if err := parseResponse(resp, &result); err != nil {
132+ return nil, err
133+ }
134+
135+ return result.Routes, nil
136+}
137+
138+// GetRoute returns a specific route by domain.
139+func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
140+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil)
141+ if err != nil {
142+ return nil, err
143+ }
144+
145+ var route domain.Route
146+ if err := parseResponse(resp, &route); err != nil {
147+ return nil, err
148+ }
149+
150+ return &route, nil
151+}
152+
153+// AddRoute adds a new route.
154+func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
155+ resp, err := c.request(ctx, http.MethodPost, "/routes", route)
156+ if err != nil {
157+ return err
158+ }
159+ return parseResponse(resp, nil)
160+}
161+
162+// UpdateRoute updates an existing route.
163+func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
164+ resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route)
165+ if err != nil {
166+ return err
167+ }
168+ return parseResponse(resp, nil)
169+}
170+
171+// RemoveRoute removes a route by domain.
172+func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
173+ resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil)
174+ if err != nil {
175+ return err
176+ }
177+ return parseResponse(resp, nil)
178+}
179+
180+// Secrets API
181+
182+// ListSecrets returns the list of secret keys for a domain.
183+func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
184+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil)
185+ if err != nil {
186+ return nil, err
187+ }
188+
189+ var result struct {
190+ Domain string `json:"domain"`
191+ Keys []string `json:"keys"`
192+ }
193+ if err := parseResponse(resp, &result); err != nil {
194+ return nil, err
195+ }
196+
197+ return result.Keys, nil
198+}
199+
200+// SetSecrets sets secrets for a domain.
201+func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
202+ resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets)
203+ if err != nil {
204+ return err
205+ }
206+ return parseResponse(resp, nil)
207+}
208+
209+// DeleteSecret removes a secret from a domain.
210+func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
211+ resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil)
212+ if err != nil {
213+ return err
214+ }
215+ return parseResponse(resp, nil)
216+}
217+
218+// Status API
219+
220+// Status represents the Gordon server status.
221+type Status struct {
222+ Routes int `json:"routes"`
223+ RegistryDomain string `json:"registry_domain"`
224+ RegistryPort int `json:"registry_port"`
225+ ServerPort int `json:"server_port"`
226+ AutoRoute bool `json:"auto_route"`
227+ NetworkIsolation bool `json:"network_isolation"`
228+ ContainerStatus map[string]string `json:"container_status"`
229+}
230+
231+// GetStatus returns the Gordon server status.
232+func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
233+ resp, err := c.request(ctx, http.MethodGet, "/status", nil)
234+ if err != nil {
235+ return nil, err
236+ }
237+
238+ var status Status
239+ if err := parseResponse(resp, &status); err != nil {
240+ return nil, err
241+ }
242+
243+ return &status, nil
244+}
245+
246+// Reload triggers a configuration reload.
247+func (c *Client) Reload(ctx context.Context) error {
248+ resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
249+ if err != nil {
250+ return err
251+ }
252+ return parseResponse(resp, nil)
253+}
254+
255+// Config API
256+
257+// Config represents the Gordon configuration.
258+type Config struct {
259+ Server struct {
260+ Port int `json:"port"`
261+ RegistryPort int `json:"registry_port"`
262+ RegistryDomain string `json:"registry_domain"`
263+ DataDir string `json:"data_dir"`
264+ } `json:"server"`
265+ AutoRoute struct {
266+ Enabled bool `json:"enabled"`
267+ } `json:"auto_route"`
268+ NetworkIsolation struct {
269+ Enabled bool `json:"enabled"`
270+ Prefix string `json:"prefix"`
271+ } `json:"network_isolation"`
272+ Routes []domain.Route `json:"routes"`
273+ ExternalRoutes map[string]string `json:"external_routes"`
274+}
275+
276+// GetConfig returns the Gordon configuration.
277+func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
278+ resp, err := c.request(ctx, http.MethodGet, "/config", nil)
279+ if err != nil {
280+ return nil, err
281+ }
282+
283+ var config Config
284+ if err := parseResponse(resp, &config); err != nil {
285+ return nil, err
286+ }
287+
288+ return &config, nil
289+}
290+
291+// Ping checks if the remote Gordon instance is reachable.
292+func (c *Client) Ping(ctx context.Context) error {
293+ _, err := c.GetStatus(ctx)
294+ return err
295+}
@@ -0,0 +1,295 @@
1+// Package remote provides an HTTP client for connecting to remote Gordon instances.
2+package remote
3+
4+import (
5+ "bytes"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strings"
12+ "time"
13+
14+ "gordon/internal/domain"
15+)
16+
17+// Client is an HTTP client for the Gordon admin API.
18+type Client struct {
19+ baseURL string
20+ token string
21+ httpClient *http.Client
22+}
23+
24+// ClientOption configures the Client.
25+type ClientOption func(*Client)
26+
27+// NewClient creates a new remote Gordon client.
28+func NewClient(baseURL string, opts ...ClientOption) *Client {
29+ // Normalize base URL
30+ baseURL = strings.TrimSuffix(baseURL, "/")
31+
32+ c := &Client{
33+ baseURL: baseURL,
34+ httpClient: &http.Client{
35+ Timeout: 30 * time.Second,
36+ },
37+ }
38+
39+ for _, opt := range opts {
40+ opt(c)
41+ }
42+
43+ return c
44+}
45+
46+// WithToken sets the authentication token.
47+func WithToken(token string) ClientOption {
48+ return func(c *Client) {
49+ c.token = token
50+ }
51+}
52+
53+// WithHTTPClient sets a custom HTTP client.
54+func WithHTTPClient(client *http.Client) ClientOption {
55+ return func(c *Client) {
56+ c.httpClient = client
57+ }
58+}
59+
60+// WithTimeout sets the HTTP client timeout.
61+func WithTimeout(timeout time.Duration) ClientOption {
62+ return func(c *Client) {
63+ c.httpClient.Timeout = timeout
64+ }
65+}
66+
67+// request performs an HTTP request to the admin API.
68+func (c *Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
69+ url := c.baseURL + "/admin" + path
70+
71+ var bodyReader io.Reader
72+ if body != nil {
73+ jsonBody, err := json.Marshal(body)
74+ if err != nil {
75+ return nil, fmt.Errorf("failed to marshal request body: %w", err)
76+ }
77+ bodyReader = bytes.NewReader(jsonBody)
78+ }
79+
80+ req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
81+ if err != nil {
82+ return nil, fmt.Errorf("failed to create request: %w", err)
83+ }
84+
85+ req.Header.Set("Content-Type", "application/json")
86+ req.Header.Set("Accept", "application/json")
87+
88+ if c.token != "" {
89+ req.Header.Set("Authorization", "Bearer "+c.token)
90+ }
91+
92+ return c.httpClient.Do(req)
93+}
94+
95+// parseResponse parses a JSON response into the given target.
96+func parseResponse(resp *http.Response, target any) error {
97+ defer resp.Body.Close()
98+
99+ if resp.StatusCode >= 400 {
100+ body, _ := io.ReadAll(resp.Body)
101+ var errResp struct {
102+ Error string `json:"error"`
103+ }
104+ if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" {
105+ return fmt.Errorf("%s: %s", resp.Status, errResp.Error)
106+ }
107+ return fmt.Errorf("%s: %s", resp.Status, string(body))
108+ }
109+
110+ if target != nil {
111+ if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
112+ return fmt.Errorf("failed to decode response: %w", err)
113+ }
114+ }
115+
116+ return nil
117+}
118+
119+// Routes API
120+
121+// ListRoutes returns all configured routes.
122+func (c *Client) ListRoutes(ctx context.Context) ([]domain.Route, error) {
123+ resp, err := c.request(ctx, http.MethodGet, "/routes", nil)
124+ if err != nil {
125+ return nil, err
126+ }
127+
128+ var result struct {
129+ Routes []domain.Route `json:"routes"`
130+ }
131+ if err := parseResponse(resp, &result); err != nil {
132+ return nil, err
133+ }
134+
135+ return result.Routes, nil
136+}
137+
138+// GetRoute returns a specific route by domain.
139+func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
140+ resp, err := c.request(ctx, http.MethodGet, "/routes/"+routeDomain, nil)
141+ if err != nil {
142+ return nil, err
143+ }
144+
145+ var route domain.Route
146+ if err := parseResponse(resp, &route); err != nil {
147+ return nil, err
148+ }
149+
150+ return &route, nil
151+}
152+
153+// AddRoute adds a new route.
154+func (c *Client) AddRoute(ctx context.Context, route domain.Route) error {
155+ resp, err := c.request(ctx, http.MethodPost, "/routes", route)
156+ if err != nil {
157+ return err
158+ }
159+ return parseResponse(resp, nil)
160+}
161+
162+// UpdateRoute updates an existing route.
163+func (c *Client) UpdateRoute(ctx context.Context, route domain.Route) error {
164+ resp, err := c.request(ctx, http.MethodPut, "/routes/"+route.Domain, route)
165+ if err != nil {
166+ return err
167+ }
168+ return parseResponse(resp, nil)
169+}
170+
171+// RemoveRoute removes a route by domain.
172+func (c *Client) RemoveRoute(ctx context.Context, routeDomain string) error {
173+ resp, err := c.request(ctx, http.MethodDelete, "/routes/"+routeDomain, nil)
174+ if err != nil {
175+ return err
176+ }
177+ return parseResponse(resp, nil)
178+}
179+
180+// Secrets API
181+
182+// ListSecrets returns the list of secret keys for a domain.
183+func (c *Client) ListSecrets(ctx context.Context, secretDomain string) ([]string, error) {
184+ resp, err := c.request(ctx, http.MethodGet, "/secrets/"+secretDomain, nil)
185+ if err != nil {
186+ return nil, err
187+ }
188+
189+ var result struct {
190+ Domain string `json:"domain"`
191+ Keys []string `json:"keys"`
192+ }
193+ if err := parseResponse(resp, &result); err != nil {
194+ return nil, err
195+ }
196+
197+ return result.Keys, nil
198+}
199+
200+// SetSecrets sets secrets for a domain.
201+func (c *Client) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
202+ resp, err := c.request(ctx, http.MethodPost, "/secrets/"+secretDomain, secrets)
203+ if err != nil {
204+ return err
205+ }
206+ return parseResponse(resp, nil)
207+}
208+
209+// DeleteSecret removes a secret from a domain.
210+func (c *Client) DeleteSecret(ctx context.Context, secretDomain, key string) error {
211+ resp, err := c.request(ctx, http.MethodDelete, "/secrets/"+secretDomain+"/"+key, nil)
212+ if err != nil {
213+ return err
214+ }
215+ return parseResponse(resp, nil)
216+}
217+
218+// Status API
219+
220+// Status represents the Gordon server status.
221+type Status struct {
222+ Routes int `json:"routes"`
223+ RegistryDomain string `json:"registry_domain"`
224+ RegistryPort int `json:"registry_port"`
225+ ServerPort int `json:"server_port"`
226+ AutoRoute bool `json:"auto_route"`
227+ NetworkIsolation bool `json:"network_isolation"`
228+ ContainerStatus map[string]string `json:"container_status"`
229+}
230+
231+// GetStatus returns the Gordon server status.
232+func (c *Client) GetStatus(ctx context.Context) (*Status, error) {
233+ resp, err := c.request(ctx, http.MethodGet, "/status", nil)
234+ if err != nil {
235+ return nil, err
236+ }
237+
238+ var status Status
239+ if err := parseResponse(resp, &status); err != nil {
240+ return nil, err
241+ }
242+
243+ return &status, nil
244+}
245+
246+// Reload triggers a configuration reload.
247+func (c *Client) Reload(ctx context.Context) error {
248+ resp, err := c.request(ctx, http.MethodPost, "/reload", nil)
249+ if err != nil {
250+ return err
251+ }
252+ return parseResponse(resp, nil)
253+}
254+
255+// Config API
256+
257+// Config represents the Gordon configuration.
258+type Config struct {
259+ Server struct {
260+ Port int `json:"port"`
261+ RegistryPort int `json:"registry_port"`
262+ RegistryDomain string `json:"registry_domain"`
263+ DataDir string `json:"data_dir"`
264+ } `json:"server"`
265+ AutoRoute struct {
266+ Enabled bool `json:"enabled"`
267+ } `json:"auto_route"`
268+ NetworkIsolation struct {
269+ Enabled bool `json:"enabled"`
270+ Prefix string `json:"prefix"`
271+ } `json:"network_isolation"`
272+ Routes []domain.Route `json:"routes"`
273+ ExternalRoutes map[string]string `json:"external_routes"`
274+}
275+
276+// GetConfig returns the Gordon configuration.
277+func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
278+ resp, err := c.request(ctx, http.MethodGet, "/config", nil)
279+ if err != nil {
280+ return nil, err
281+ }
282+
283+ var config Config
284+ if err := parseResponse(resp, &config); err != nil {
285+ return nil, err
286+ }
287+
288+ return &config, nil
289+}
290+
291+// Ping checks if the remote Gordon instance is reachable.
292+func (c *Client) Ping(ctx context.Context) error {
293+ _, err := c.GetStatus(ctx)
294+ return err
295+}
A internal/adapters/in/cli/remote/config.go +270 -0
@@ -0,0 +1,270 @@
1+package remote
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/pelletier/go-toml/v2"
9+)
10+
11+// ClientConfig represents the client-mode configuration.
12+type ClientConfig struct {
13+ Client ClientSettings `toml:"client"`
14+ Targets map[string]TargetEntry `toml:"targets"`
15+ Active string `toml:"active"` // Active target name
16+}
17+
18+// ClientSettings represents the [client] section.
19+type ClientSettings struct {
20+ Mode string `toml:"mode"` // "local" (default) or "remote"
21+ Target string `toml:"target"` // Remote Gordon URL
22+ Token string `toml:"token"` // Auth token
23+ TokenEnv string `toml:"token_env"` // Env var name for token
24+}
25+
26+// TargetEntry represents a saved target in [targets.*].
27+type TargetEntry struct {
28+ URL string `toml:"url"`
29+ Token string `toml:"token,omitempty"`
30+ TokenEnv string `toml:"token_env,omitempty"`
31+}
32+
33+// DefaultClientConfigPath returns the default client config path.
34+func DefaultClientConfigPath() string {
35+ configDir, err := os.UserConfigDir()
36+ if err != nil {
37+ configDir = os.Getenv("HOME")
38+ }
39+ return filepath.Join(configDir, "gordon", "gordon.toml")
40+}
41+
42+// DefaultTargetsPath returns the default targets config path.
43+func DefaultTargetsPath() string {
44+ configDir, err := os.UserConfigDir()
45+ if err != nil {
46+ configDir = os.Getenv("HOME")
47+ }
48+ return filepath.Join(configDir, "gordon", "targets.toml")
49+}
50+
51+// LoadClientConfig loads the client configuration.
52+func LoadClientConfig(path string) (*ClientConfig, error) {
53+ if path == "" {
54+ path = DefaultClientConfigPath()
55+ }
56+
57+ data, err := os.ReadFile(path)
58+ if err != nil {
59+ if os.IsNotExist(err) {
60+ // Return default config
61+ return &ClientConfig{
62+ Client: ClientSettings{
63+ Mode: "local",
64+ },
65+ Targets: make(map[string]TargetEntry),
66+ }, nil
67+ }
68+ return nil, fmt.Errorf("failed to read config: %w", err)
69+ }
70+
71+ var config ClientConfig
72+ if err := toml.Unmarshal(data, &config); err != nil {
73+ return nil, fmt.Errorf("failed to parse config: %w", err)
74+ }
75+
76+ if config.Targets == nil {
77+ config.Targets = make(map[string]TargetEntry)
78+ }
79+
80+ return &config, nil
81+}
82+
83+// LoadTargets loads the targets configuration.
84+func LoadTargets(path string) (*ClientConfig, error) {
85+ if path == "" {
86+ path = DefaultTargetsPath()
87+ }
88+
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ if os.IsNotExist(err) {
92+ return &ClientConfig{
93+ Targets: make(map[string]TargetEntry),
94+ }, nil
95+ }
96+ return nil, fmt.Errorf("failed to read targets: %w", err)
97+ }
98+
99+ var config ClientConfig
100+ if err := toml.Unmarshal(data, &config); err != nil {
101+ return nil, fmt.Errorf("failed to parse targets: %w", err)
102+ }
103+
104+ if config.Targets == nil {
105+ config.Targets = make(map[string]TargetEntry)
106+ }
107+
108+ return &config, nil
109+}
110+
111+// SaveTargets saves the targets configuration.
112+func SaveTargets(path string, config *ClientConfig) error {
113+ if path == "" {
114+ path = DefaultTargetsPath()
115+ }
116+
117+ // Ensure directory exists
118+ dir := filepath.Dir(path)
119+ if err := os.MkdirAll(dir, 0700); err != nil {
120+ return fmt.Errorf("failed to create config directory: %w", err)
121+ }
122+
123+ data, err := toml.Marshal(config)
124+ if err != nil {
125+ return fmt.Errorf("failed to marshal targets: %w", err)
126+ }
127+
128+ if err := os.WriteFile(path, data, 0600); err != nil {
129+ return fmt.Errorf("failed to write targets: %w", err)
130+ }
131+
132+ return nil
133+}
134+
135+// ResolveTarget resolves the target URL and token from configuration.
136+// Precedence: flag > env > config > active target
137+func ResolveTarget(flagTarget, flagToken string) (url, token string, isRemote bool) {
138+ config, _ := LoadClientConfig("")
139+ targets, _ := LoadTargets("")
140+
141+ url, isRemote = resolveTargetURL(flagTarget, config, targets)
142+ if isRemote {
143+ token = resolveToken(flagToken, config, targets)
144+ }
145+
146+ return url, token, isRemote
147+}
148+
149+// resolveTargetURL resolves the target URL from various sources.
150+func resolveTargetURL(flagTarget string, config *ClientConfig, targets *ClientConfig) (string, bool) {
151+ // 1. Check flag
152+ if flagTarget != "" {
153+ return flagTarget, true
154+ }
155+
156+ // 2. Check environment variable
157+ if envTarget := os.Getenv("GORDON_TARGET"); envTarget != "" {
158+ return envTarget, true
159+ }
160+
161+ // 3. Check client config
162+ if config != nil && config.Client.Mode == "remote" && config.Client.Target != "" {
163+ return config.Client.Target, true
164+ }
165+
166+ // 4. Check active target
167+ if targets != nil && targets.Active != "" {
168+ if target, ok := targets.Targets[targets.Active]; ok {
169+ return target.URL, true
170+ }
171+ }
172+
173+ return "", false
174+}
175+
176+// resolveToken resolves the authentication token from various sources.
177+func resolveToken(flagToken string, config *ClientConfig, targets *ClientConfig) string {
178+ // 1. Flag token takes precedence
179+ if flagToken != "" {
180+ return flagToken
181+ }
182+
183+ // 2. Environment variable
184+ if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
185+ return envToken
186+ }
187+
188+ // 3. Config token
189+ if config != nil {
190+ if config.Client.Token != "" {
191+ return config.Client.Token
192+ }
193+ if config.Client.TokenEnv != "" {
194+ return os.Getenv(config.Client.TokenEnv)
195+ }
196+ }
197+
198+ // 4. Active target token
199+ if targets != nil && targets.Active != "" {
200+ if target, ok := targets.Targets[targets.Active]; ok {
201+ if target.Token != "" {
202+ return target.Token
203+ }
204+ if target.TokenEnv != "" {
205+ return os.Getenv(target.TokenEnv)
206+ }
207+ }
208+ }
209+
210+ return ""
211+}
212+
213+// AddTarget adds a new target to the targets configuration.
214+func AddTarget(name, url, token string) error {
215+ config, err := LoadTargets("")
216+ if err != nil {
217+ return err
218+ }
219+
220+ config.Targets[name] = TargetEntry{
221+ URL: url,
222+ Token: token,
223+ }
224+
225+ return SaveTargets("", config)
226+}
227+
228+// RemoveTarget removes a target from the targets configuration.
229+func RemoveTarget(name string) error {
230+ config, err := LoadTargets("")
231+ if err != nil {
232+ return err
233+ }
234+
235+ delete(config.Targets, name)
236+
237+ // Clear active if it was the removed target
238+ if config.Active == name {
239+ config.Active = ""
240+ }
241+
242+ return SaveTargets("", config)
243+}
244+
245+// SetActiveTarget sets the active target.
246+func SetActiveTarget(name string) error {
247+ config, err := LoadTargets("")
248+ if err != nil {
249+ return err
250+ }
251+
252+ // Verify target exists
253+ if _, ok := config.Targets[name]; !ok {
254+ return fmt.Errorf("target '%s' not found", name)
255+ }
256+
257+ config.Active = name
258+
259+ return SaveTargets("", config)
260+}
261+
262+// ListTargets returns all saved targets.
263+func ListTargets() (map[string]TargetEntry, string, error) {
264+ config, err := LoadTargets("")
265+ if err != nil {
266+ return nil, "", err
267+ }
268+
269+ return config.Targets, config.Active, nil
270+}
@@ -0,0 +1,270 @@
1+package remote
2+
3+import (
4+ "fmt"
5+ "os"
6+ "path/filepath"
7+
8+ "github.com/pelletier/go-toml/v2"
9+)
10+
11+// ClientConfig represents the client-mode configuration.
12+type ClientConfig struct {
13+ Client ClientSettings `toml:"client"`
14+ Targets map[string]TargetEntry `toml:"targets"`
15+ Active string `toml:"active"` // Active target name
16+}
17+
18+// ClientSettings represents the [client] section.
19+type ClientSettings struct {
20+ Mode string `toml:"mode"` // "local" (default) or "remote"
21+ Target string `toml:"target"` // Remote Gordon URL
22+ Token string `toml:"token"` // Auth token
23+ TokenEnv string `toml:"token_env"` // Env var name for token
24+}
25+
26+// TargetEntry represents a saved target in [targets.*].
27+type TargetEntry struct {
28+ URL string `toml:"url"`
29+ Token string `toml:"token,omitempty"`
30+ TokenEnv string `toml:"token_env,omitempty"`
31+}
32+
33+// DefaultClientConfigPath returns the default client config path.
34+func DefaultClientConfigPath() string {
35+ configDir, err := os.UserConfigDir()
36+ if err != nil {
37+ configDir = os.Getenv("HOME")
38+ }
39+ return filepath.Join(configDir, "gordon", "gordon.toml")
40+}
41+
42+// DefaultTargetsPath returns the default targets config path.
43+func DefaultTargetsPath() string {
44+ configDir, err := os.UserConfigDir()
45+ if err != nil {
46+ configDir = os.Getenv("HOME")
47+ }
48+ return filepath.Join(configDir, "gordon", "targets.toml")
49+}
50+
51+// LoadClientConfig loads the client configuration.
52+func LoadClientConfig(path string) (*ClientConfig, error) {
53+ if path == "" {
54+ path = DefaultClientConfigPath()
55+ }
56+
57+ data, err := os.ReadFile(path)
58+ if err != nil {
59+ if os.IsNotExist(err) {
60+ // Return default config
61+ return &ClientConfig{
62+ Client: ClientSettings{
63+ Mode: "local",
64+ },
65+ Targets: make(map[string]TargetEntry),
66+ }, nil
67+ }
68+ return nil, fmt.Errorf("failed to read config: %w", err)
69+ }
70+
71+ var config ClientConfig
72+ if err := toml.Unmarshal(data, &config); err != nil {
73+ return nil, fmt.Errorf("failed to parse config: %w", err)
74+ }
75+
76+ if config.Targets == nil {
77+ config.Targets = make(map[string]TargetEntry)
78+ }
79+
80+ return &config, nil
81+}
82+
83+// LoadTargets loads the targets configuration.
84+func LoadTargets(path string) (*ClientConfig, error) {
85+ if path == "" {
86+ path = DefaultTargetsPath()
87+ }
88+
89+ data, err := os.ReadFile(path)
90+ if err != nil {
91+ if os.IsNotExist(err) {
92+ return &ClientConfig{
93+ Targets: make(map[string]TargetEntry),
94+ }, nil
95+ }
96+ return nil, fmt.Errorf("failed to read targets: %w", err)
97+ }
98+
99+ var config ClientConfig
100+ if err := toml.Unmarshal(data, &config); err != nil {
101+ return nil, fmt.Errorf("failed to parse targets: %w", err)
102+ }
103+
104+ if config.Targets == nil {
105+ config.Targets = make(map[string]TargetEntry)
106+ }
107+
108+ return &config, nil
109+}
110+
111+// SaveTargets saves the targets configuration.
112+func SaveTargets(path string, config *ClientConfig) error {
113+ if path == "" {
114+ path = DefaultTargetsPath()
115+ }
116+
117+ // Ensure directory exists
118+ dir := filepath.Dir(path)
119+ if err := os.MkdirAll(dir, 0700); err != nil {
120+ return fmt.Errorf("failed to create config directory: %w", err)
121+ }
122+
123+ data, err := toml.Marshal(config)
124+ if err != nil {
125+ return fmt.Errorf("failed to marshal targets: %w", err)
126+ }
127+
128+ if err := os.WriteFile(path, data, 0600); err != nil {
129+ return fmt.Errorf("failed to write targets: %w", err)
130+ }
131+
132+ return nil
133+}
134+
135+// ResolveTarget resolves the target URL and token from configuration.
136+// Precedence: flag > env > config > active target
137+func ResolveTarget(flagTarget, flagToken string) (url, token string, isRemote bool) {
138+ config, _ := LoadClientConfig("")
139+ targets, _ := LoadTargets("")
140+
141+ url, isRemote = resolveTargetURL(flagTarget, config, targets)
142+ if isRemote {
143+ token = resolveToken(flagToken, config, targets)
144+ }
145+
146+ return url, token, isRemote
147+}
148+
149+// resolveTargetURL resolves the target URL from various sources.
150+func resolveTargetURL(flagTarget string, config *ClientConfig, targets *ClientConfig) (string, bool) {
151+ // 1. Check flag
152+ if flagTarget != "" {
153+ return flagTarget, true
154+ }
155+
156+ // 2. Check environment variable
157+ if envTarget := os.Getenv("GORDON_TARGET"); envTarget != "" {
158+ return envTarget, true
159+ }
160+
161+ // 3. Check client config
162+ if config != nil && config.Client.Mode == "remote" && config.Client.Target != "" {
163+ return config.Client.Target, true
164+ }
165+
166+ // 4. Check active target
167+ if targets != nil && targets.Active != "" {
168+ if target, ok := targets.Targets[targets.Active]; ok {
169+ return target.URL, true
170+ }
171+ }
172+
173+ return "", false
174+}
175+
176+// resolveToken resolves the authentication token from various sources.
177+func resolveToken(flagToken string, config *ClientConfig, targets *ClientConfig) string {
178+ // 1. Flag token takes precedence
179+ if flagToken != "" {
180+ return flagToken
181+ }
182+
183+ // 2. Environment variable
184+ if envToken := os.Getenv("GORDON_TOKEN"); envToken != "" {
185+ return envToken
186+ }
187+
188+ // 3. Config token
189+ if config != nil {
190+ if config.Client.Token != "" {
191+ return config.Client.Token
192+ }
193+ if config.Client.TokenEnv != "" {
194+ return os.Getenv(config.Client.TokenEnv)
195+ }
196+ }
197+
198+ // 4. Active target token
199+ if targets != nil && targets.Active != "" {
200+ if target, ok := targets.Targets[targets.Active]; ok {
201+ if target.Token != "" {
202+ return target.Token
203+ }
204+ if target.TokenEnv != "" {
205+ return os.Getenv(target.TokenEnv)
206+ }
207+ }
208+ }
209+
210+ return ""
211+}
212+
213+// AddTarget adds a new target to the targets configuration.
214+func AddTarget(name, url, token string) error {
215+ config, err := LoadTargets("")
216+ if err != nil {
217+ return err
218+ }
219+
220+ config.Targets[name] = TargetEntry{
221+ URL: url,
222+ Token: token,
223+ }
224+
225+ return SaveTargets("", config)
226+}
227+
228+// RemoveTarget removes a target from the targets configuration.
229+func RemoveTarget(name string) error {
230+ config, err := LoadTargets("")
231+ if err != nil {
232+ return err
233+ }
234+
235+ delete(config.Targets, name)
236+
237+ // Clear active if it was the removed target
238+ if config.Active == name {
239+ config.Active = ""
240+ }
241+
242+ return SaveTargets("", config)
243+}
244+
245+// SetActiveTarget sets the active target.
246+func SetActiveTarget(name string) error {
247+ config, err := LoadTargets("")
248+ if err != nil {
249+ return err
250+ }
251+
252+ // Verify target exists
253+ if _, ok := config.Targets[name]; !ok {
254+ return fmt.Errorf("target '%s' not found", name)
255+ }
256+
257+ config.Active = name
258+
259+ return SaveTargets("", config)
260+}
261+
262+// ListTargets returns all saved targets.
263+func ListTargets() (map[string]TargetEntry, string, error) {
264+ config, err := LoadTargets("")
265+ if err != nil {
266+ return nil, "", err
267+ }
268+
269+ return config.Targets, config.Active, nil
270+}
M internal/adapters/in/cli/root.go +35 -19
@@ -3,8 +3,7 @@
33 package cli
44
55 import (
6- "context"
7-
6+ "gordon/internal/adapters/in/cli/remote"
87 "gordon/internal/app"
98
109 "github.com/spf13/cobra"
@@ -15,6 +14,10 @@ var (
1514 Version = "dev"
1615 Commit = "unknown"
1716 BuildDate = "unknown"
17+
18+ // Global flags for remote targeting
19+ targetFlag string
20+ tokenFlag string
1821 )
1922
2023 // NewRootCmd creates the root command for Gordon CLI.
@@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command {
2629 a Docker registry with automatic container deployment capabilities.
2730
2831 It listens for image pushes and automatically deploys containers based on
29-configuration rules, making it ideal for single-server deployments.`,
32+configuration rules, making it ideal for single-server deployments.
33+
34+The CLI can target remote Gordon instances using the --target flag or
35+GORDON_TARGET environment variable.`,
3036 }
3137
32- // Add subcommands
33- rootCmd.AddCommand(newStartCmd())
38+ // Add persistent flags for remote targeting
39+ rootCmd.PersistentFlags().StringVar(&targetFlag, "target", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
40+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote target")
41+
42+ // Server commands
43+ rootCmd.AddCommand(newServeCmd())
44+ rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve
3445 rootCmd.AddCommand(newReloadCmd())
3546 rootCmd.AddCommand(newDeployCmd())
3647 rootCmd.AddCommand(newLogsCmd())
3748 rootCmd.AddCommand(newVersionCmd())
3849 rootCmd.AddCommand(newAuthCmd())
3950
51+ // Remote management commands
52+ rootCmd.AddCommand(newRoutesCmd())
53+ rootCmd.AddCommand(newSecretsCmd())
54+ rootCmd.AddCommand(newTargetsCmd())
55+ rootCmd.AddCommand(newStatusCmd())
56+
4057 return rootCmd
4158 }
4259
43-// newStartCmd creates the start command.
44-func newStartCmd() *cobra.Command {
45- var configPath string
46-
47- cmd := &cobra.Command{
48- Use: "start",
49- Short: "Start the Gordon server",
50- Long: `Start the Gordon server, including the registry and proxy components.`,
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return app.Run(context.Background(), configPath)
53- },
60+// GetRemoteClient returns a remote client if targeting a remote instance,
61+// or nil if running locally.
62+func GetRemoteClient() (*remote.Client, bool) {
63+ url, token, isRemote := remote.ResolveTarget(targetFlag, tokenFlag)
64+ if !isRemote {
65+ return nil, false
5466 }
5567
56- // Add flags
57- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
68+ client := remote.NewClient(url, remote.WithToken(token))
69+ return client, true
70+}
5871
59- return cmd
72+// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
73+func IsRemoteMode() bool {
74+ _, _, isRemote := remote.ResolveTarget(targetFlag, tokenFlag)
75+ return isRemote
6076 }
6177
6278 // newReloadCmd creates the reload command.
@@ -3,8 +3,7 @@
3 package cli3 package cli
4 4
5 import (5 import (
6- "context"6+ "gordon/internal/adapters/in/cli/remote"
7-
8 "gordon/internal/app"7 "gordon/internal/app"
9 8
10 "github.com/spf13/cobra"9 "github.com/spf13/cobra"
@@ -15,6 +14,10 @@ var (
15 Version = "dev"14 Version = "dev"
16 Commit = "unknown"15 Commit = "unknown"
17 BuildDate = "unknown"16 BuildDate = "unknown"
17+
18+ // Global flags for remote targeting
19+ targetFlag string
20+ tokenFlag string
18 )21 )
19 22
20 // NewRootCmd creates the root command for Gordon CLI.23 // NewRootCmd creates the root command for Gordon CLI.
@@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command {
26 a Docker registry with automatic container deployment capabilities.29 a Docker registry with automatic container deployment capabilities.
27 30
28 It listens for image pushes and automatically deploys containers based on31 It listens for image pushes and automatically deploys containers based on
29-configuration rules, making it ideal for single-server deployments.`,32+configuration rules, making it ideal for single-server deployments.
33+
34+The CLI can target remote Gordon instances using the --target flag or
35+GORDON_TARGET environment variable.`,
30 }36 }
31 37
32- // Add subcommands38+ // Add persistent flags for remote targeting
33- rootCmd.AddCommand(newStartCmd())39+ rootCmd.PersistentFlags().StringVar(&targetFlag, "target", "", "Remote Gordon URL (e.g., https://gordon.mydomain.com)")
40+ rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "Authentication token for remote target")
41+
42+ // Server commands
43+ rootCmd.AddCommand(newServeCmd())
44+ rootCmd.AddCommand(newStartCmd()) // Deprecated alias for serve
34 rootCmd.AddCommand(newReloadCmd())45 rootCmd.AddCommand(newReloadCmd())
35 rootCmd.AddCommand(newDeployCmd())46 rootCmd.AddCommand(newDeployCmd())
36 rootCmd.AddCommand(newLogsCmd())47 rootCmd.AddCommand(newLogsCmd())
37 rootCmd.AddCommand(newVersionCmd())48 rootCmd.AddCommand(newVersionCmd())
38 rootCmd.AddCommand(newAuthCmd())49 rootCmd.AddCommand(newAuthCmd())
39 50
51+ // Remote management commands
52+ rootCmd.AddCommand(newRoutesCmd())
53+ rootCmd.AddCommand(newSecretsCmd())
54+ rootCmd.AddCommand(newTargetsCmd())
55+ rootCmd.AddCommand(newStatusCmd())
56+
40 return rootCmd57 return rootCmd
41 }58 }
42 59
43-// newStartCmd creates the start command.60+// GetRemoteClient returns a remote client if targeting a remote instance,
44-func newStartCmd() *cobra.Command {61+// or nil if running locally.
45- var configPath string62+func GetRemoteClient() (*remote.Client, bool) {
46-63+ url, token, isRemote := remote.ResolveTarget(targetFlag, tokenFlag)
47- cmd := &cobra.Command{64+ if !isRemote {
48- Use: "start",65+ return nil, false
49- Short: "Start the Gordon server",
50- Long: `Start the Gordon server, including the registry and proxy components.`,
51- RunE: func(cmd *cobra.Command, args []string) error {
52- return app.Run(context.Background(), configPath)
53- },
54 }66 }
55 67
56- // Add flags68+ client := remote.NewClient(url, remote.WithToken(token))
57- cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")69+ return client, true
70+}
58 71
59- return cmd72+// IsRemoteMode returns true if CLI is targeting a remote Gordon instance.
73+func IsRemoteMode() bool {
74+ _, _, isRemote := remote.ResolveTarget(targetFlag, tokenFlag)
75+ return isRemote
60 }76 }
61 77
62 // newReloadCmd creates the reload command.78 // newReloadCmd creates the reload command.
A internal/adapters/in/cli/routes.go +271 -0
@@ -0,0 +1,271 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "gordon/internal/adapters/in/cli/ui/components"
10+ "gordon/internal/adapters/in/cli/ui/styles"
11+ "gordon/internal/domain"
12+
13+ "github.com/spf13/cobra"
14+)
15+
16+// truncateImage shortens long image references for display.
17+// For digests (image@sha256:...), shows first 12 chars of digest.
18+// For tags, truncates to maxLen with ellipsis if needed.
19+func truncateImage(image string, maxLen int) string {
20+ // Handle digest references: image@sha256:abc123...
21+ if idx := strings.Index(image, "@sha256:"); idx != -1 {
22+ name := image[:idx]
23+ digest := image[idx+8:] // Skip "@sha256:"
24+ if len(digest) > 12 {
25+ digest = digest[:12]
26+ }
27+ short := fmt.Sprintf("%s@sha256:%s…", name, digest)
28+ if len(short) > maxLen {
29+ // Truncate name if still too long
30+ available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis
31+ if available > 3 {
32+ name = name[:available-1] + "…"
33+ }
34+ short = fmt.Sprintf("%s@sha256:%s…", name, digest)
35+ }
36+ return short
37+ }
38+
39+ // Regular tag: truncate if needed
40+ if len(image) > maxLen {
41+ return image[:maxLen-1] + "…"
42+ }
43+ return image
44+}
45+
46+// newRoutesCmd creates the routes command group.
47+func newRoutesCmd() *cobra.Command {
48+ cmd := &cobra.Command{
49+ Use: "routes",
50+ Short: "Manage routes",
51+ Long: `Manage Gordon routes. Routes map domains to container images.
52+
53+When targeting a remote Gordon instance (via --target flag or GORDON_TARGET env var),
54+these commands operate on the remote server. Otherwise, they require access to
55+the local Gordon configuration.`,
56+ }
57+
58+ cmd.AddCommand(newRoutesListCmd())
59+ cmd.AddCommand(newRoutesAddCmd())
60+ cmd.AddCommand(newRoutesRemoveCmd())
61+
62+ return cmd
63+}
64+
65+// newRoutesListCmd creates the routes list command.
66+func newRoutesListCmd() *cobra.Command {
67+ return &cobra.Command{
68+ Use: "list",
69+ Short: "List all routes",
70+ RunE: func(cmd *cobra.Command, args []string) error {
71+ ctx := context.Background()
72+
73+ client, isRemote := GetRemoteClient()
74+ if !isRemote {
75+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
76+ fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly."))
77+ return nil
78+ }
79+
80+ routes, err := client.ListRoutes(ctx)
81+ if err != nil {
82+ return fmt.Errorf("failed to list routes: %w", err)
83+ }
84+
85+ if len(routes) == 0 {
86+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
87+ return nil
88+ }
89+
90+ // Get status for each route
91+ status, _ := client.GetStatus(ctx)
92+ containerStatus := make(map[string]string)
93+ if status != nil {
94+ containerStatus = status.ContainerStatus
95+ }
96+
97+ // Build table rows
98+ const imageColWidth = 35
99+ rows := make([][]string, len(routes))
100+ for i, route := range routes {
101+ routeStatus := containerStatus[route.Domain]
102+ if routeStatus == "" {
103+ routeStatus = "unknown"
104+ }
105+ statusBadge := components.ContainerStatusBadge(routeStatus)
106+ displayImage := truncateImage(route.Image, imageColWidth)
107+ rows[i] = []string{route.Domain, displayImage, statusBadge}
108+ }
109+
110+ // Render table
111+ table := components.NewTable(
112+ components.WithColumns([]components.TableColumn{
113+ {Title: "Domain", Width: 25},
114+ {Title: "Image", Width: imageColWidth},
115+ {Title: "Status", Width: 12},
116+ }),
117+ components.WithRows(rows),
118+ )
119+
120+ fmt.Println(styles.Theme.Title.Render("Routes"))
121+ fmt.Println()
122+ fmt.Println(table.View())
123+
124+ return nil
125+ },
126+ }
127+}
128+
129+// newRoutesAddCmd creates the routes add command.
130+func newRoutesAddCmd() *cobra.Command {
131+ var image string
132+
133+ cmd := &cobra.Command{
134+ Use: "add <domain>",
135+ Short: "Add a new route",
136+ Long: `Add a new route mapping a domain to a container image.
137+
138+Examples:
139+ gordon routes add app.mydomain.com --image myapp:latest
140+ gordon --target https://gordon.mydomain.com routes add api.mydomain.com --image api:v2`,
141+ Args: cobra.ExactArgs(1),
142+ RunE: func(cmd *cobra.Command, args []string) error {
143+ ctx := context.Background()
144+ routeDomain := args[0]
145+
146+ client, isRemote := GetRemoteClient()
147+ if !isRemote {
148+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
149+ return nil
150+ }
151+
152+ if image == "" {
153+ fmt.Println(styles.RenderError("--image flag is required"))
154+ return nil
155+ }
156+
157+ route := domain.Route{
158+ Domain: routeDomain,
159+ Image: image,
160+ }
161+
162+ if err := client.AddRoute(ctx, route); err != nil {
163+ return fmt.Errorf("failed to add route: %w", err)
164+ }
165+
166+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
167+ return nil
168+ },
169+ }
170+
171+ cmd.Flags().StringVarP(&image, "image", "i", "", "Container image (required)")
172+ _ = cmd.MarkFlagRequired("image")
173+
174+ return cmd
175+}
176+
177+// newRoutesRemoveCmd creates the routes remove command.
178+func newRoutesRemoveCmd() *cobra.Command {
179+ var force bool
180+
181+ cmd := &cobra.Command{
182+ Use: "remove <domain>",
183+ Short: "Remove a route",
184+ Long: `Remove a route by its domain name.
185+
186+Examples:
187+ gordon routes remove app.mydomain.com
188+ gordon routes remove app.mydomain.com --force`,
189+ Args: cobra.ExactArgs(1),
190+ RunE: func(cmd *cobra.Command, args []string) error {
191+ ctx := context.Background()
192+ routeDomain := args[0]
193+
194+ client, isRemote := GetRemoteClient()
195+ if !isRemote {
196+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
197+ return nil
198+ }
199+
200+ // Confirm unless --force
201+ if !force {
202+ confirmed, err := components.RunConfirm(
203+ fmt.Sprintf("Remove route '%s'?", routeDomain),
204+ components.WithDescription("This will stop and remove the associated container."),
205+ )
206+ if err != nil {
207+ return err
208+ }
209+ if !confirmed {
210+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
211+ return nil
212+ }
213+ }
214+
215+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
216+ return fmt.Errorf("failed to remove route: %w", err)
217+ }
218+
219+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
220+ return nil
221+ },
222+ }
223+
224+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
225+
226+ return cmd
227+}
228+
229+// newStatusCmd creates the status command.
230+func newStatusCmd() *cobra.Command {
231+ return &cobra.Command{
232+ Use: "status",
233+ Short: "Show Gordon server status",
234+ RunE: func(cmd *cobra.Command, args []string) error {
235+ ctx := context.Background()
236+
237+ client, isRemote := GetRemoteClient()
238+ if !isRemote {
239+ fmt.Println(styles.RenderError("status command requires --target flag or GORDON_TARGET env var"))
240+ os.Exit(1)
241+ }
242+
243+ status, err := client.GetStatus(ctx)
244+ if err != nil {
245+ return fmt.Errorf("failed to get status: %w", err)
246+ }
247+
248+ // Display status
249+ fmt.Println(styles.Theme.Title.Render("Gordon Status"))
250+ fmt.Println()
251+
252+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Registry:"), status.RegistryDomain)
253+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
254+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
255+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
256+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
257+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
258+
259+ if len(status.ContainerStatus) > 0 {
260+ fmt.Println()
261+ fmt.Println(styles.Theme.Bold.Render("Container Status:"))
262+ for domain, containerStatus := range status.ContainerStatus {
263+ badge := components.ContainerStatusBadge(containerStatus)
264+ fmt.Printf(" %s: %s\n", domain, badge)
265+ }
266+ }
267+
268+ return nil
269+ },
270+ }
271+}
@@ -0,0 +1,271 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "strings"
8+
9+ "gordon/internal/adapters/in/cli/ui/components"
10+ "gordon/internal/adapters/in/cli/ui/styles"
11+ "gordon/internal/domain"
12+
13+ "github.com/spf13/cobra"
14+)
15+
16+// truncateImage shortens long image references for display.
17+// For digests (image@sha256:...), shows first 12 chars of digest.
18+// For tags, truncates to maxLen with ellipsis if needed.
19+func truncateImage(image string, maxLen int) string {
20+ // Handle digest references: image@sha256:abc123...
21+ if idx := strings.Index(image, "@sha256:"); idx != -1 {
22+ name := image[:idx]
23+ digest := image[idx+8:] // Skip "@sha256:"
24+ if len(digest) > 12 {
25+ digest = digest[:12]
26+ }
27+ short := fmt.Sprintf("%s@sha256:%s…", name, digest)
28+ if len(short) > maxLen {
29+ // Truncate name if still too long
30+ available := maxLen - len("@sha256:") - 12 - 1 // 1 for ellipsis
31+ if available > 3 {
32+ name = name[:available-1] + "…"
33+ }
34+ short = fmt.Sprintf("%s@sha256:%s…", name, digest)
35+ }
36+ return short
37+ }
38+
39+ // Regular tag: truncate if needed
40+ if len(image) > maxLen {
41+ return image[:maxLen-1] + "…"
42+ }
43+ return image
44+}
45+
46+// newRoutesCmd creates the routes command group.
47+func newRoutesCmd() *cobra.Command {
48+ cmd := &cobra.Command{
49+ Use: "routes",
50+ Short: "Manage routes",
51+ Long: `Manage Gordon routes. Routes map domains to container images.
52+
53+When targeting a remote Gordon instance (via --target flag or GORDON_TARGET env var),
54+these commands operate on the remote server. Otherwise, they require access to
55+the local Gordon configuration.`,
56+ }
57+
58+ cmd.AddCommand(newRoutesListCmd())
59+ cmd.AddCommand(newRoutesAddCmd())
60+ cmd.AddCommand(newRoutesRemoveCmd())
61+
62+ return cmd
63+}
64+
65+// newRoutesListCmd creates the routes list command.
66+func newRoutesListCmd() *cobra.Command {
67+ return &cobra.Command{
68+ Use: "list",
69+ Short: "List all routes",
70+ RunE: func(cmd *cobra.Command, args []string) error {
71+ ctx := context.Background()
72+
73+ client, isRemote := GetRemoteClient()
74+ if !isRemote {
75+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
76+ fmt.Println(styles.Theme.Muted.Render("Local route management is not yet supported. Use the config file directly."))
77+ return nil
78+ }
79+
80+ routes, err := client.ListRoutes(ctx)
81+ if err != nil {
82+ return fmt.Errorf("failed to list routes: %w", err)
83+ }
84+
85+ if len(routes) == 0 {
86+ fmt.Println(styles.Theme.Muted.Render("No routes configured"))
87+ return nil
88+ }
89+
90+ // Get status for each route
91+ status, _ := client.GetStatus(ctx)
92+ containerStatus := make(map[string]string)
93+ if status != nil {
94+ containerStatus = status.ContainerStatus
95+ }
96+
97+ // Build table rows
98+ const imageColWidth = 35
99+ rows := make([][]string, len(routes))
100+ for i, route := range routes {
101+ routeStatus := containerStatus[route.Domain]
102+ if routeStatus == "" {
103+ routeStatus = "unknown"
104+ }
105+ statusBadge := components.ContainerStatusBadge(routeStatus)
106+ displayImage := truncateImage(route.Image, imageColWidth)
107+ rows[i] = []string{route.Domain, displayImage, statusBadge}
108+ }
109+
110+ // Render table
111+ table := components.NewTable(
112+ components.WithColumns([]components.TableColumn{
113+ {Title: "Domain", Width: 25},
114+ {Title: "Image", Width: imageColWidth},
115+ {Title: "Status", Width: 12},
116+ }),
117+ components.WithRows(rows),
118+ )
119+
120+ fmt.Println(styles.Theme.Title.Render("Routes"))
121+ fmt.Println()
122+ fmt.Println(table.View())
123+
124+ return nil
125+ },
126+ }
127+}
128+
129+// newRoutesAddCmd creates the routes add command.
130+func newRoutesAddCmd() *cobra.Command {
131+ var image string
132+
133+ cmd := &cobra.Command{
134+ Use: "add <domain>",
135+ Short: "Add a new route",
136+ Long: `Add a new route mapping a domain to a container image.
137+
138+Examples:
139+ gordon routes add app.mydomain.com --image myapp:latest
140+ gordon --target https://gordon.mydomain.com routes add api.mydomain.com --image api:v2`,
141+ Args: cobra.ExactArgs(1),
142+ RunE: func(cmd *cobra.Command, args []string) error {
143+ ctx := context.Background()
144+ routeDomain := args[0]
145+
146+ client, isRemote := GetRemoteClient()
147+ if !isRemote {
148+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
149+ return nil
150+ }
151+
152+ if image == "" {
153+ fmt.Println(styles.RenderError("--image flag is required"))
154+ return nil
155+ }
156+
157+ route := domain.Route{
158+ Domain: routeDomain,
159+ Image: image,
160+ }
161+
162+ if err := client.AddRoute(ctx, route); err != nil {
163+ return fmt.Errorf("failed to add route: %w", err)
164+ }
165+
166+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
167+ return nil
168+ },
169+ }
170+
171+ cmd.Flags().StringVarP(&image, "image", "i", "", "Container image (required)")
172+ _ = cmd.MarkFlagRequired("image")
173+
174+ return cmd
175+}
176+
177+// newRoutesRemoveCmd creates the routes remove command.
178+func newRoutesRemoveCmd() *cobra.Command {
179+ var force bool
180+
181+ cmd := &cobra.Command{
182+ Use: "remove <domain>",
183+ Short: "Remove a route",
184+ Long: `Remove a route by its domain name.
185+
186+Examples:
187+ gordon routes remove app.mydomain.com
188+ gordon routes remove app.mydomain.com --force`,
189+ Args: cobra.ExactArgs(1),
190+ RunE: func(cmd *cobra.Command, args []string) error {
191+ ctx := context.Background()
192+ routeDomain := args[0]
193+
194+ client, isRemote := GetRemoteClient()
195+ if !isRemote {
196+ fmt.Println(styles.RenderError("routes command requires --target flag or GORDON_TARGET env var"))
197+ return nil
198+ }
199+
200+ // Confirm unless --force
201+ if !force {
202+ confirmed, err := components.RunConfirm(
203+ fmt.Sprintf("Remove route '%s'?", routeDomain),
204+ components.WithDescription("This will stop and remove the associated container."),
205+ )
206+ if err != nil {
207+ return err
208+ }
209+ if !confirmed {
210+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
211+ return nil
212+ }
213+ }
214+
215+ if err := client.RemoveRoute(ctx, routeDomain); err != nil {
216+ return fmt.Errorf("failed to remove route: %w", err)
217+ }
218+
219+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
220+ return nil
221+ },
222+ }
223+
224+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
225+
226+ return cmd
227+}
228+
229+// newStatusCmd creates the status command.
230+func newStatusCmd() *cobra.Command {
231+ return &cobra.Command{
232+ Use: "status",
233+ Short: "Show Gordon server status",
234+ RunE: func(cmd *cobra.Command, args []string) error {
235+ ctx := context.Background()
236+
237+ client, isRemote := GetRemoteClient()
238+ if !isRemote {
239+ fmt.Println(styles.RenderError("status command requires --target flag or GORDON_TARGET env var"))
240+ os.Exit(1)
241+ }
242+
243+ status, err := client.GetStatus(ctx)
244+ if err != nil {
245+ return fmt.Errorf("failed to get status: %w", err)
246+ }
247+
248+ // Display status
249+ fmt.Println(styles.Theme.Title.Render("Gordon Status"))
250+ fmt.Println()
251+
252+ fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Registry:"), status.RegistryDomain)
253+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
254+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
255+ fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
256+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
257+ fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
258+
259+ if len(status.ContainerStatus) > 0 {
260+ fmt.Println()
261+ fmt.Println(styles.Theme.Bold.Render("Container Status:"))
262+ for domain, containerStatus := range status.ContainerStatus {
263+ badge := components.ContainerStatusBadge(containerStatus)
264+ fmt.Printf(" %s: %s\n", domain, badge)
265+ }
266+ }
267+
268+ return nil
269+ },
270+ }
271+}
A internal/adapters/in/cli/secrets.go +196 -0
@@ -0,0 +1,196 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "gordon/internal/adapters/in/cli/ui/components"
9+ "gordon/internal/adapters/in/cli/ui/styles"
10+
11+ "github.com/spf13/cobra"
12+)
13+
14+// newSecretsCmd creates the secrets command group.
15+func newSecretsCmd() *cobra.Command {
16+ cmd := &cobra.Command{
17+ Use: "secrets",
18+ Short: "Manage secrets",
19+ Long: `Manage secrets (environment variables) for routes.
20+
21+Secrets are stored per-domain and injected into containers as environment variables.
22+
23+When targeting a remote Gordon instance (via --target flag or GORDON_TARGET env var),
24+these commands operate on the remote server.`,
25+ }
26+
27+ cmd.AddCommand(newSecretsListCmd())
28+ cmd.AddCommand(newSecretsSetCmd())
29+ cmd.AddCommand(newSecretsRemoveCmd())
30+
31+ return cmd
32+}
33+
34+// newSecretsListCmd creates the secrets list command.
35+func newSecretsListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list <domain>",
38+ Short: "List secrets for a domain",
39+ Long: `List all secret keys configured for a domain.
40+
41+Note: Only secret keys are shown, not values (for security).
42+
43+Examples:
44+ gordon secrets list app.mydomain.com
45+ gordon --target https://gordon.mydomain.com secrets list api.mydomain.com`,
46+ Args: cobra.ExactArgs(1),
47+ RunE: func(cmd *cobra.Command, args []string) error {
48+ ctx := context.Background()
49+ domain := args[0]
50+
51+ client, isRemote := GetRemoteClient()
52+ if !isRemote {
53+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
54+ fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported."))
55+ return nil
56+ }
57+
58+ keys, err := client.ListSecrets(ctx, domain)
59+ if err != nil {
60+ return fmt.Errorf("failed to list secrets: %w", err)
61+ }
62+
63+ if len(keys) == 0 {
64+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain)))
65+ return nil
66+ }
67+
68+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain)))
69+ fmt.Println()
70+
71+ // Build table rows
72+ rows := make([][]string, len(keys))
73+ for i, key := range keys {
74+ rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
75+ }
76+
77+ // Render table
78+ table := components.NewTable(
79+ components.WithColumns([]components.TableColumn{
80+ {Title: "Key", Width: 30},
81+ {Title: "Value", Width: 20},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newSecretsSetCmd creates the secrets set command.
94+func newSecretsSetCmd() *cobra.Command {
95+ return &cobra.Command{
96+ Use: "set <domain> <KEY=value>...",
97+ Short: "Set secrets for a domain",
98+ Long: `Set one or more secrets for a domain.
99+
100+Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
101+
102+Examples:
103+ gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
104+ gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
105+ gordon --target https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
106+ Args: cobra.MinimumNArgs(2),
107+ RunE: func(cmd *cobra.Command, args []string) error {
108+ ctx := context.Background()
109+ domain := args[0]
110+ pairs := args[1:]
111+
112+ client, isRemote := GetRemoteClient()
113+ if !isRemote {
114+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
115+ return nil
116+ }
117+
118+ // Parse KEY=value pairs
119+ secrets := make(map[string]string)
120+ for _, pair := range pairs {
121+ parts := strings.SplitN(pair, "=", 2)
122+ if len(parts) != 2 {
123+ fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
124+ return nil
125+ }
126+ secrets[parts[0]] = parts[1]
127+ }
128+
129+ if err := client.SetSecrets(ctx, domain, secrets); err != nil {
130+ return fmt.Errorf("failed to set secrets: %w", err)
131+ }
132+
133+ if len(secrets) == 1 {
134+ for key := range secrets {
135+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
136+ }
137+ } else {
138+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain)))
139+ }
140+
141+ return nil
142+ },
143+ }
144+}
145+
146+// newSecretsRemoveCmd creates the secrets remove command.
147+func newSecretsRemoveCmd() *cobra.Command {
148+ var force bool
149+
150+ cmd := &cobra.Command{
151+ Use: "remove <domain> <key>",
152+ Short: "Remove a secret",
153+ Long: `Remove a secret from a domain.
154+
155+Examples:
156+ gordon secrets remove app.mydomain.com OLD_API_KEY
157+ gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
158+ Args: cobra.ExactArgs(2),
159+ RunE: func(cmd *cobra.Command, args []string) error {
160+ ctx := context.Background()
161+ domain := args[0]
162+ key := args[1]
163+
164+ client, isRemote := GetRemoteClient()
165+ if !isRemote {
166+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
167+ return nil
168+ }
169+
170+ // Confirm unless --force
171+ if !force {
172+ confirmed, err := components.RunConfirm(
173+ fmt.Sprintf("Remove secret '%s' from %s?", key, domain),
174+ )
175+ if err != nil {
176+ return err
177+ }
178+ if !confirmed {
179+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
180+ return nil
181+ }
182+ }
183+
184+ if err := client.DeleteSecret(ctx, domain, key); err != nil {
185+ return fmt.Errorf("failed to remove secret: %w", err)
186+ }
187+
188+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
189+ return nil
190+ },
191+ }
192+
193+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
194+
195+ return cmd
196+}
@@ -0,0 +1,196 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "gordon/internal/adapters/in/cli/ui/components"
9+ "gordon/internal/adapters/in/cli/ui/styles"
10+
11+ "github.com/spf13/cobra"
12+)
13+
14+// newSecretsCmd creates the secrets command group.
15+func newSecretsCmd() *cobra.Command {
16+ cmd := &cobra.Command{
17+ Use: "secrets",
18+ Short: "Manage secrets",
19+ Long: `Manage secrets (environment variables) for routes.
20+
21+Secrets are stored per-domain and injected into containers as environment variables.
22+
23+When targeting a remote Gordon instance (via --target flag or GORDON_TARGET env var),
24+these commands operate on the remote server.`,
25+ }
26+
27+ cmd.AddCommand(newSecretsListCmd())
28+ cmd.AddCommand(newSecretsSetCmd())
29+ cmd.AddCommand(newSecretsRemoveCmd())
30+
31+ return cmd
32+}
33+
34+// newSecretsListCmd creates the secrets list command.
35+func newSecretsListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list <domain>",
38+ Short: "List secrets for a domain",
39+ Long: `List all secret keys configured for a domain.
40+
41+Note: Only secret keys are shown, not values (for security).
42+
43+Examples:
44+ gordon secrets list app.mydomain.com
45+ gordon --target https://gordon.mydomain.com secrets list api.mydomain.com`,
46+ Args: cobra.ExactArgs(1),
47+ RunE: func(cmd *cobra.Command, args []string) error {
48+ ctx := context.Background()
49+ domain := args[0]
50+
51+ client, isRemote := GetRemoteClient()
52+ if !isRemote {
53+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
54+ fmt.Println(styles.Theme.Muted.Render("Local secret management is not yet supported."))
55+ return nil
56+ }
57+
58+ keys, err := client.ListSecrets(ctx, domain)
59+ if err != nil {
60+ return fmt.Errorf("failed to list secrets: %w", err)
61+ }
62+
63+ if len(keys) == 0 {
64+ fmt.Println(styles.Theme.Muted.Render(fmt.Sprintf("No secrets configured for %s", domain)))
65+ return nil
66+ }
67+
68+ fmt.Println(styles.Theme.Title.Render(fmt.Sprintf("Secrets for %s", domain)))
69+ fmt.Println()
70+
71+ // Build table rows
72+ rows := make([][]string, len(keys))
73+ for i, key := range keys {
74+ rows[i] = []string{key, styles.Theme.Muted.Render("(hidden)")}
75+ }
76+
77+ // Render table
78+ table := components.NewTable(
79+ components.WithColumns([]components.TableColumn{
80+ {Title: "Key", Width: 30},
81+ {Title: "Value", Width: 20},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newSecretsSetCmd creates the secrets set command.
94+func newSecretsSetCmd() *cobra.Command {
95+ return &cobra.Command{
96+ Use: "set <domain> <KEY=value>...",
97+ Short: "Set secrets for a domain",
98+ Long: `Set one or more secrets for a domain.
99+
100+Secrets are specified as KEY=value pairs. Multiple secrets can be set at once.
101+
102+Examples:
103+ gordon secrets set app.mydomain.com DATABASE_URL=postgres://localhost/db
104+ gordon secrets set app.mydomain.com API_KEY=secret123 DEBUG=false
105+ gordon --target https://gordon.mydomain.com secrets set api.mydomain.com TOKEN=abc`,
106+ Args: cobra.MinimumNArgs(2),
107+ RunE: func(cmd *cobra.Command, args []string) error {
108+ ctx := context.Background()
109+ domain := args[0]
110+ pairs := args[1:]
111+
112+ client, isRemote := GetRemoteClient()
113+ if !isRemote {
114+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
115+ return nil
116+ }
117+
118+ // Parse KEY=value pairs
119+ secrets := make(map[string]string)
120+ for _, pair := range pairs {
121+ parts := strings.SplitN(pair, "=", 2)
122+ if len(parts) != 2 {
123+ fmt.Println(styles.RenderError(fmt.Sprintf("Invalid format: %s (expected KEY=value)", pair)))
124+ return nil
125+ }
126+ secrets[parts[0]] = parts[1]
127+ }
128+
129+ if err := client.SetSecrets(ctx, domain, secrets); err != nil {
130+ return fmt.Errorf("failed to set secrets: %w", err)
131+ }
132+
133+ if len(secrets) == 1 {
134+ for key := range secrets {
135+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret set: %s", key)))
136+ }
137+ } else {
138+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Set %d secrets for %s", len(secrets), domain)))
139+ }
140+
141+ return nil
142+ },
143+ }
144+}
145+
146+// newSecretsRemoveCmd creates the secrets remove command.
147+func newSecretsRemoveCmd() *cobra.Command {
148+ var force bool
149+
150+ cmd := &cobra.Command{
151+ Use: "remove <domain> <key>",
152+ Short: "Remove a secret",
153+ Long: `Remove a secret from a domain.
154+
155+Examples:
156+ gordon secrets remove app.mydomain.com OLD_API_KEY
157+ gordon secrets remove app.mydomain.com OLD_API_KEY --force`,
158+ Args: cobra.ExactArgs(2),
159+ RunE: func(cmd *cobra.Command, args []string) error {
160+ ctx := context.Background()
161+ domain := args[0]
162+ key := args[1]
163+
164+ client, isRemote := GetRemoteClient()
165+ if !isRemote {
166+ fmt.Println(styles.RenderError("secrets command requires --target flag or GORDON_TARGET env var"))
167+ return nil
168+ }
169+
170+ // Confirm unless --force
171+ if !force {
172+ confirmed, err := components.RunConfirm(
173+ fmt.Sprintf("Remove secret '%s' from %s?", key, domain),
174+ )
175+ if err != nil {
176+ return err
177+ }
178+ if !confirmed {
179+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
180+ return nil
181+ }
182+ }
183+
184+ if err := client.DeleteSecret(ctx, domain, key); err != nil {
185+ return fmt.Errorf("failed to remove secret: %w", err)
186+ }
187+
188+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Secret removed: %s", key)))
189+ return nil
190+ },
191+ }
192+
193+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
194+
195+ return cmd
196+}
A internal/adapters/in/cli/serve.go +52 -0
@@ -0,0 +1,52 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "gordon/internal/app"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newServeCmd creates the serve command.
14+func newServeCmd() *cobra.Command {
15+ var configPath string
16+
17+ cmd := &cobra.Command{
18+ Use: "serve",
19+ Short: "Start the Gordon server",
20+ Long: `Start the Gordon server, including the registry and proxy components.`,
21+ RunE: func(cmd *cobra.Command, args []string) error {
22+ return app.Run(context.Background(), configPath)
23+ },
24+ }
25+
26+ // Add flags
27+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
28+
29+ return cmd
30+}
31+
32+// newStartCmd creates a deprecated alias for serve.
33+func newStartCmd() *cobra.Command {
34+ var configPath string
35+
36+ cmd := &cobra.Command{
37+ Use: "start",
38+ Short: "Start the Gordon server (deprecated: use 'serve')",
39+ Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`,
40+ Deprecated: "use 'gordon serve' instead",
41+ Hidden: true, // Hide from help but still works
42+ RunE: func(cmd *cobra.Command, args []string) error {
43+ fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")
44+ return app.Run(context.Background(), configPath)
45+ },
46+ }
47+
48+ // Add flags (same as serve)
49+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
50+
51+ return cmd
52+}
@@ -0,0 +1,52 @@
1+package cli
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+
8+ "gordon/internal/app"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newServeCmd creates the serve command.
14+func newServeCmd() *cobra.Command {
15+ var configPath string
16+
17+ cmd := &cobra.Command{
18+ Use: "serve",
19+ Short: "Start the Gordon server",
20+ Long: `Start the Gordon server, including the registry and proxy components.`,
21+ RunE: func(cmd *cobra.Command, args []string) error {
22+ return app.Run(context.Background(), configPath)
23+ },
24+ }
25+
26+ // Add flags
27+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
28+
29+ return cmd
30+}
31+
32+// newStartCmd creates a deprecated alias for serve.
33+func newStartCmd() *cobra.Command {
34+ var configPath string
35+
36+ cmd := &cobra.Command{
37+ Use: "start",
38+ Short: "Start the Gordon server (deprecated: use 'serve')",
39+ Long: `Start the Gordon server. This command is deprecated, please use 'gordon serve' instead.`,
40+ Deprecated: "use 'gordon serve' instead",
41+ Hidden: true, // Hide from help but still works
42+ RunE: func(cmd *cobra.Command, args []string) error {
43+ fmt.Fprintln(os.Stderr, "\nWarning: 'gordon start' is deprecated, use 'gordon serve' instead")
44+ return app.Run(context.Background(), configPath)
45+ },
46+ }
47+
48+ // Add flags (same as serve)
49+ cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
50+
51+ return cmd
52+}
A internal/adapters/in/cli/targets.go +246 -0
@@ -0,0 +1,246 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "gordon/internal/adapters/in/cli/remote"
7+ "gordon/internal/adapters/in/cli/ui/components"
8+ "gordon/internal/adapters/in/cli/ui/styles"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newTargetsCmd creates the targets command group.
14+func newTargetsCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "targets",
17+ Short: "Manage saved remote targets",
18+ Long: `Manage saved remote Gordon targets.
19+
20+Targets allow you to save frequently used remote Gordon instances
21+and quickly switch between them.
22+
23+Configuration is stored in ~/.config/gordon/targets.toml`,
24+ }
25+
26+ cmd.AddCommand(newTargetsListCmd())
27+ cmd.AddCommand(newTargetsAddCmd())
28+ cmd.AddCommand(newTargetsRemoveCmd())
29+ cmd.AddCommand(newTargetsUseCmd())
30+
31+ return cmd
32+}
33+
34+// newTargetsListCmd creates the targets list command.
35+func newTargetsListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list",
38+ Short: "List saved targets",
39+ RunE: func(cmd *cobra.Command, args []string) error {
40+ targets, active, err := remote.ListTargets()
41+ if err != nil {
42+ return fmt.Errorf("failed to list targets: %w", err)
43+ }
44+
45+ if len(targets) == 0 {
46+ fmt.Println(styles.Theme.Muted.Render("No targets configured"))
47+ fmt.Println()
48+ fmt.Println("Add a target with:")
49+ fmt.Println(styles.Theme.Bold.Render(" gordon targets add <name> <url>"))
50+ return nil
51+ }
52+
53+ fmt.Println(styles.Theme.Title.Render("Saved Targets"))
54+ fmt.Println()
55+
56+ // Build table rows
57+ rows := make([][]string, 0, len(targets))
58+ for name, target := range targets {
59+ status := ""
60+ if name == active {
61+ status = styles.Theme.Success.Render("active")
62+ }
63+
64+ // Mask token if present
65+ tokenStatus := styles.Theme.Muted.Render("none")
66+ if target.Token != "" {
67+ tokenStatus = styles.Theme.Success.Render("set")
68+ } else if target.TokenEnv != "" {
69+ tokenStatus = fmt.Sprintf("$%s", target.TokenEnv)
70+ }
71+
72+ rows = append(rows, []string{name, target.URL, tokenStatus, status})
73+ }
74+
75+ // Render table
76+ table := components.NewTable(
77+ components.WithColumns([]components.TableColumn{
78+ {Title: "Name", Width: 15},
79+ {Title: "URL", Width: 35},
80+ {Title: "Token", Width: 15},
81+ {Title: "Status", Width: 10},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newTargetsAddCmd creates the targets add command.
94+func newTargetsAddCmd() *cobra.Command {
95+ var token string
96+ var tokenEnv string
97+
98+ cmd := &cobra.Command{
99+ Use: "add <name> <url>",
100+ Short: "Add a new target",
101+ Long: `Add a new saved target.
102+
103+Examples:
104+ gordon targets add prod https://gordon.mydomain.com
105+ gordon targets add prod https://gordon.mydomain.com --token eyJ...
106+ gordon targets add staging https://staging.mydomain.com --token-env STAGING_TOKEN`,
107+ Args: cobra.ExactArgs(2),
108+ RunE: func(cmd *cobra.Command, args []string) error {
109+ name := args[0]
110+ url := args[1]
111+
112+ // If token-env is provided, use that instead of token
113+ finalToken := token
114+ if tokenEnv != "" {
115+ // Store token_env reference instead of actual token
116+ if err := addTargetWithEnv(name, url, tokenEnv); err != nil {
117+ return fmt.Errorf("failed to add target: %w", err)
118+ }
119+ } else {
120+ if err := remote.AddTarget(name, url, finalToken); err != nil {
121+ return fmt.Errorf("failed to add target: %w", err)
122+ }
123+ }
124+
125+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Target added: %s -> %s", name, url)))
126+
127+ if token == "" && tokenEnv == "" {
128+ fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
129+ }
130+
131+ return nil
132+ },
133+ }
134+
135+ cmd.Flags().StringVar(&token, "token", "", "Authentication token")
136+ cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
137+
138+ return cmd
139+}
140+
141+// addTargetWithEnv adds a target with a token environment variable reference.
142+func addTargetWithEnv(name, url, tokenEnv string) error {
143+ config, err := remote.LoadTargets("")
144+ if err != nil {
145+ return err
146+ }
147+
148+ config.Targets[name] = remote.TargetEntry{
149+ URL: url,
150+ TokenEnv: tokenEnv,
151+ }
152+
153+ return remote.SaveTargets("", config)
154+}
155+
156+// newTargetsRemoveCmd creates the targets remove command.
157+func newTargetsRemoveCmd() *cobra.Command {
158+ var force bool
159+
160+ cmd := &cobra.Command{
161+ Use: "remove <name>",
162+ Aliases: []string{"rm"},
163+ Short: "Remove a target",
164+ Args: cobra.ExactArgs(1),
165+ RunE: func(cmd *cobra.Command, args []string) error {
166+ name := args[0]
167+
168+ // Check if target exists
169+ targets, active, err := remote.ListTargets()
170+ if err != nil {
171+ return fmt.Errorf("failed to list targets: %w", err)
172+ }
173+
174+ if _, exists := targets[name]; !exists {
175+ fmt.Println(styles.RenderError(fmt.Sprintf("Target '%s' not found", name)))
176+ return nil
177+ }
178+
179+ // Confirm unless --force
180+ if !force {
181+ message := fmt.Sprintf("Remove target '%s'?", name)
182+ if name == active {
183+ message = fmt.Sprintf("Remove target '%s'? (currently active)", name)
184+ }
185+
186+ confirmed, err := components.RunConfirm(message)
187+ if err != nil {
188+ return err
189+ }
190+ if !confirmed {
191+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
192+ return nil
193+ }
194+ }
195+
196+ if err := remote.RemoveTarget(name); err != nil {
197+ return fmt.Errorf("failed to remove target: %w", err)
198+ }
199+
200+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Target removed: %s", name)))
201+ return nil
202+ },
203+ }
204+
205+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
206+
207+ return cmd
208+}
209+
210+// newTargetsUseCmd creates the targets use command.
211+func newTargetsUseCmd() *cobra.Command {
212+ return &cobra.Command{
213+ Use: "use <name>",
214+ Short: "Set the active target",
215+ Long: `Set a target as the active default.
216+
217+When a target is active, it will be used automatically for remote commands
218+without needing to specify --target.
219+
220+Examples:
221+ gordon targets use prod
222+ gordon routes list # Uses prod target automatically`,
223+ Args: cobra.ExactArgs(1),
224+ RunE: func(cmd *cobra.Command, args []string) error {
225+ name := args[0]
226+
227+ if err := remote.SetActiveTarget(name); err != nil {
228+ if err.Error() == fmt.Sprintf("target '%s' not found", name) {
229+ fmt.Println(styles.RenderError(fmt.Sprintf("Target '%s' not found", name)))
230+ fmt.Println()
231+ fmt.Println("Available targets:")
232+
233+ targets, _, _ := remote.ListTargets()
234+ for targetName := range targets {
235+ fmt.Printf(" %s\n", targetName)
236+ }
237+ return nil
238+ }
239+ return fmt.Errorf("failed to set active target: %w", err)
240+ }
241+
242+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active target set to '%s'", name)))
243+ return nil
244+ },
245+ }
246+}
@@ -0,0 +1,246 @@
1+package cli
2+
3+import (
4+ "fmt"
5+
6+ "gordon/internal/adapters/in/cli/remote"
7+ "gordon/internal/adapters/in/cli/ui/components"
8+ "gordon/internal/adapters/in/cli/ui/styles"
9+
10+ "github.com/spf13/cobra"
11+)
12+
13+// newTargetsCmd creates the targets command group.
14+func newTargetsCmd() *cobra.Command {
15+ cmd := &cobra.Command{
16+ Use: "targets",
17+ Short: "Manage saved remote targets",
18+ Long: `Manage saved remote Gordon targets.
19+
20+Targets allow you to save frequently used remote Gordon instances
21+and quickly switch between them.
22+
23+Configuration is stored in ~/.config/gordon/targets.toml`,
24+ }
25+
26+ cmd.AddCommand(newTargetsListCmd())
27+ cmd.AddCommand(newTargetsAddCmd())
28+ cmd.AddCommand(newTargetsRemoveCmd())
29+ cmd.AddCommand(newTargetsUseCmd())
30+
31+ return cmd
32+}
33+
34+// newTargetsListCmd creates the targets list command.
35+func newTargetsListCmd() *cobra.Command {
36+ return &cobra.Command{
37+ Use: "list",
38+ Short: "List saved targets",
39+ RunE: func(cmd *cobra.Command, args []string) error {
40+ targets, active, err := remote.ListTargets()
41+ if err != nil {
42+ return fmt.Errorf("failed to list targets: %w", err)
43+ }
44+
45+ if len(targets) == 0 {
46+ fmt.Println(styles.Theme.Muted.Render("No targets configured"))
47+ fmt.Println()
48+ fmt.Println("Add a target with:")
49+ fmt.Println(styles.Theme.Bold.Render(" gordon targets add <name> <url>"))
50+ return nil
51+ }
52+
53+ fmt.Println(styles.Theme.Title.Render("Saved Targets"))
54+ fmt.Println()
55+
56+ // Build table rows
57+ rows := make([][]string, 0, len(targets))
58+ for name, target := range targets {
59+ status := ""
60+ if name == active {
61+ status = styles.Theme.Success.Render("active")
62+ }
63+
64+ // Mask token if present
65+ tokenStatus := styles.Theme.Muted.Render("none")
66+ if target.Token != "" {
67+ tokenStatus = styles.Theme.Success.Render("set")
68+ } else if target.TokenEnv != "" {
69+ tokenStatus = fmt.Sprintf("$%s", target.TokenEnv)
70+ }
71+
72+ rows = append(rows, []string{name, target.URL, tokenStatus, status})
73+ }
74+
75+ // Render table
76+ table := components.NewTable(
77+ components.WithColumns([]components.TableColumn{
78+ {Title: "Name", Width: 15},
79+ {Title: "URL", Width: 35},
80+ {Title: "Token", Width: 15},
81+ {Title: "Status", Width: 10},
82+ }),
83+ components.WithRows(rows),
84+ )
85+
86+ fmt.Println(table.View())
87+
88+ return nil
89+ },
90+ }
91+}
92+
93+// newTargetsAddCmd creates the targets add command.
94+func newTargetsAddCmd() *cobra.Command {
95+ var token string
96+ var tokenEnv string
97+
98+ cmd := &cobra.Command{
99+ Use: "add <name> <url>",
100+ Short: "Add a new target",
101+ Long: `Add a new saved target.
102+
103+Examples:
104+ gordon targets add prod https://gordon.mydomain.com
105+ gordon targets add prod https://gordon.mydomain.com --token eyJ...
106+ gordon targets add staging https://staging.mydomain.com --token-env STAGING_TOKEN`,
107+ Args: cobra.ExactArgs(2),
108+ RunE: func(cmd *cobra.Command, args []string) error {
109+ name := args[0]
110+ url := args[1]
111+
112+ // If token-env is provided, use that instead of token
113+ finalToken := token
114+ if tokenEnv != "" {
115+ // Store token_env reference instead of actual token
116+ if err := addTargetWithEnv(name, url, tokenEnv); err != nil {
117+ return fmt.Errorf("failed to add target: %w", err)
118+ }
119+ } else {
120+ if err := remote.AddTarget(name, url, finalToken); err != nil {
121+ return fmt.Errorf("failed to add target: %w", err)
122+ }
123+ }
124+
125+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Target added: %s -> %s", name, url)))
126+
127+ if token == "" && tokenEnv == "" {
128+ fmt.Println(styles.Theme.Muted.Render("Tip: Add a token with --token or --token-env for authenticated access"))
129+ }
130+
131+ return nil
132+ },
133+ }
134+
135+ cmd.Flags().StringVar(&token, "token", "", "Authentication token")
136+ cmd.Flags().StringVar(&tokenEnv, "token-env", "", "Environment variable containing token")
137+
138+ return cmd
139+}
140+
141+// addTargetWithEnv adds a target with a token environment variable reference.
142+func addTargetWithEnv(name, url, tokenEnv string) error {
143+ config, err := remote.LoadTargets("")
144+ if err != nil {
145+ return err
146+ }
147+
148+ config.Targets[name] = remote.TargetEntry{
149+ URL: url,
150+ TokenEnv: tokenEnv,
151+ }
152+
153+ return remote.SaveTargets("", config)
154+}
155+
156+// newTargetsRemoveCmd creates the targets remove command.
157+func newTargetsRemoveCmd() *cobra.Command {
158+ var force bool
159+
160+ cmd := &cobra.Command{
161+ Use: "remove <name>",
162+ Aliases: []string{"rm"},
163+ Short: "Remove a target",
164+ Args: cobra.ExactArgs(1),
165+ RunE: func(cmd *cobra.Command, args []string) error {
166+ name := args[0]
167+
168+ // Check if target exists
169+ targets, active, err := remote.ListTargets()
170+ if err != nil {
171+ return fmt.Errorf("failed to list targets: %w", err)
172+ }
173+
174+ if _, exists := targets[name]; !exists {
175+ fmt.Println(styles.RenderError(fmt.Sprintf("Target '%s' not found", name)))
176+ return nil
177+ }
178+
179+ // Confirm unless --force
180+ if !force {
181+ message := fmt.Sprintf("Remove target '%s'?", name)
182+ if name == active {
183+ message = fmt.Sprintf("Remove target '%s'? (currently active)", name)
184+ }
185+
186+ confirmed, err := components.RunConfirm(message)
187+ if err != nil {
188+ return err
189+ }
190+ if !confirmed {
191+ fmt.Println(styles.Theme.Muted.Render("Cancelled"))
192+ return nil
193+ }
194+ }
195+
196+ if err := remote.RemoveTarget(name); err != nil {
197+ return fmt.Errorf("failed to remove target: %w", err)
198+ }
199+
200+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Target removed: %s", name)))
201+ return nil
202+ },
203+ }
204+
205+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
206+
207+ return cmd
208+}
209+
210+// newTargetsUseCmd creates the targets use command.
211+func newTargetsUseCmd() *cobra.Command {
212+ return &cobra.Command{
213+ Use: "use <name>",
214+ Short: "Set the active target",
215+ Long: `Set a target as the active default.
216+
217+When a target is active, it will be used automatically for remote commands
218+without needing to specify --target.
219+
220+Examples:
221+ gordon targets use prod
222+ gordon routes list # Uses prod target automatically`,
223+ Args: cobra.ExactArgs(1),
224+ RunE: func(cmd *cobra.Command, args []string) error {
225+ name := args[0]
226+
227+ if err := remote.SetActiveTarget(name); err != nil {
228+ if err.Error() == fmt.Sprintf("target '%s' not found", name) {
229+ fmt.Println(styles.RenderError(fmt.Sprintf("Target '%s' not found", name)))
230+ fmt.Println()
231+ fmt.Println("Available targets:")
232+
233+ targets, _, _ := remote.ListTargets()
234+ for targetName := range targets {
235+ fmt.Printf(" %s\n", targetName)
236+ }
237+ return nil
238+ }
239+ return fmt.Errorf("failed to set active target: %w", err)
240+ }
241+
242+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Active target set to '%s'", name)))
243+ return nil
244+ },
245+ }
246+}
A internal/adapters/in/cli/ui/components/confirm.go +202 -0
@@ -0,0 +1,202 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+)
12+
13+// ConfirmResult represents the result of a confirmation dialog.
14+type ConfirmResult int
15+
16+const (
17+ ConfirmPending ConfirmResult = iota
18+ ConfirmYes
19+ ConfirmNo
20+ ConfirmCancelled
21+)
22+
23+// ConfirmModel is a Yes/No confirmation dialog.
24+type ConfirmModel struct {
25+ question string
26+ description string
27+ yesLabel string
28+ noLabel string
29+ focused bool // true = Yes is focused, false = No is focused
30+ result ConfirmResult
31+ width int
32+
33+ // Styles
34+ questionStyle lipgloss.Style
35+ descriptionStyle lipgloss.Style
36+ buttonStyle lipgloss.Style
37+ focusedStyle lipgloss.Style
38+}
39+
40+// ConfirmOption configures a ConfirmModel.
41+type ConfirmOption func(*ConfirmModel)
42+
43+// NewConfirm creates a new confirmation dialog.
44+func NewConfirm(question string, opts ...ConfirmOption) ConfirmModel {
45+ m := ConfirmModel{
46+ question: question,
47+ yesLabel: "Yes",
48+ noLabel: "No",
49+ focused: false, // Default to No
50+ result: ConfirmPending,
51+ width: 50,
52+ questionStyle: styles.Theme.Bold,
53+ descriptionStyle: styles.Theme.Muted,
54+ buttonStyle: lipgloss.NewStyle().
55+ Padding(0, 2).
56+ Foreground(styles.ColorText),
57+ focusedStyle: lipgloss.NewStyle().
58+ Padding(0, 2).
59+ Bold(true).
60+ Foreground(styles.ColorBg).
61+ Background(styles.ColorPrimary),
62+ }
63+
64+ for _, opt := range opts {
65+ opt(&m)
66+ }
67+
68+ return m
69+}
70+
71+// WithDescription adds a description to the confirmation.
72+func WithDescription(desc string) ConfirmOption {
73+ return func(m *ConfirmModel) {
74+ m.description = desc
75+ }
76+}
77+
78+// WithDefaultYes sets Yes as the default selection.
79+func WithDefaultYes() ConfirmOption {
80+ return func(m *ConfirmModel) {
81+ m.focused = true
82+ }
83+}
84+
85+// WithLabels customizes the Yes/No labels.
86+func WithLabels(yes, no string) ConfirmOption {
87+ return func(m *ConfirmModel) {
88+ m.yesLabel = yes
89+ m.noLabel = no
90+ }
91+}
92+
93+// WithWidth sets the dialog width.
94+func WithWidth(w int) ConfirmOption {
95+ return func(m *ConfirmModel) {
96+ m.width = w
97+ }
98+}
99+
100+// Init implements tea.Model.
101+func (m ConfirmModel) Init() tea.Cmd {
102+ return nil
103+}
104+
105+// Update implements tea.Model.
106+func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107+ switch msg := msg.(type) {
108+ case tea.KeyMsg:
109+ switch msg.String() {
110+ case "left", "h", "tab", "shift+tab":
111+ m.focused = !m.focused
112+ case "right", "l":
113+ m.focused = !m.focused
114+ case "y", "Y":
115+ m.result = ConfirmYes
116+ return m, tea.Quit
117+ case "n", "N":
118+ m.result = ConfirmNo
119+ return m, tea.Quit
120+ case "enter":
121+ if m.focused {
122+ m.result = ConfirmYes
123+ } else {
124+ m.result = ConfirmNo
125+ }
126+ return m, tea.Quit
127+ case "esc", "ctrl+c", "q":
128+ m.result = ConfirmCancelled
129+ return m, tea.Quit
130+ }
131+ }
132+ return m, nil
133+}
134+
135+// View implements tea.Model.
136+func (m ConfirmModel) View() string {
137+ var b strings.Builder
138+
139+ // Question
140+ b.WriteString(m.questionStyle.Render(m.question))
141+ b.WriteString("\n")
142+
143+ // Description
144+ if m.description != "" {
145+ b.WriteString(m.descriptionStyle.Render(m.description))
146+ b.WriteString("\n")
147+ }
148+ b.WriteString("\n")
149+
150+ // Buttons
151+ var yesButton, noButton string
152+ if m.focused {
153+ yesButton = m.focusedStyle.Render(m.yesLabel)
154+ noButton = m.buttonStyle.Render(m.noLabel)
155+ } else {
156+ yesButton = m.buttonStyle.Render(m.yesLabel)
157+ noButton = m.focusedStyle.Render(m.noLabel)
158+ }
159+
160+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton)
161+ b.WriteString(buttons)
162+ b.WriteString("\n\n")
163+
164+ // Help
165+ help := styles.RenderKeyHelp("y/n", "select") + " " +
166+ styles.RenderKeyHelp("enter", "confirm") + " " +
167+ styles.RenderKeyHelp("esc", "cancel")
168+ b.WriteString(help)
169+
170+ return b.String()
171+}
172+
173+// Result returns the confirmation result.
174+func (m ConfirmModel) Result() ConfirmResult {
175+ return m.result
176+}
177+
178+// Confirmed returns true if the user confirmed.
179+func (m ConfirmModel) Confirmed() bool {
180+ return m.result == ConfirmYes
181+}
182+
183+// Cancelled returns true if the user cancelled.
184+func (m ConfirmModel) Cancelled() bool {
185+ return m.result == ConfirmCancelled
186+}
187+
188+// RunConfirm runs a confirmation dialog and returns the result.
189+// This is a convenience function for simple confirmations.
190+func RunConfirm(question string, opts ...ConfirmOption) (bool, error) {
191+ m := NewConfirm(question, opts...)
192+ p := tea.NewProgram(m)
193+ finalModel, err := p.Run()
194+ if err != nil {
195+ return false, fmt.Errorf("error running confirmation: %w", err)
196+ }
197+ result := finalModel.(ConfirmModel)
198+ if result.Cancelled() {
199+ return false, nil
200+ }
201+ return result.Confirmed(), nil
202+}
@@ -0,0 +1,202 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ tea "github.com/charmbracelet/bubbletea"
10+ "github.com/charmbracelet/lipgloss"
11+)
12+
13+// ConfirmResult represents the result of a confirmation dialog.
14+type ConfirmResult int
15+
16+const (
17+ ConfirmPending ConfirmResult = iota
18+ ConfirmYes
19+ ConfirmNo
20+ ConfirmCancelled
21+)
22+
23+// ConfirmModel is a Yes/No confirmation dialog.
24+type ConfirmModel struct {
25+ question string
26+ description string
27+ yesLabel string
28+ noLabel string
29+ focused bool // true = Yes is focused, false = No is focused
30+ result ConfirmResult
31+ width int
32+
33+ // Styles
34+ questionStyle lipgloss.Style
35+ descriptionStyle lipgloss.Style
36+ buttonStyle lipgloss.Style
37+ focusedStyle lipgloss.Style
38+}
39+
40+// ConfirmOption configures a ConfirmModel.
41+type ConfirmOption func(*ConfirmModel)
42+
43+// NewConfirm creates a new confirmation dialog.
44+func NewConfirm(question string, opts ...ConfirmOption) ConfirmModel {
45+ m := ConfirmModel{
46+ question: question,
47+ yesLabel: "Yes",
48+ noLabel: "No",
49+ focused: false, // Default to No
50+ result: ConfirmPending,
51+ width: 50,
52+ questionStyle: styles.Theme.Bold,
53+ descriptionStyle: styles.Theme.Muted,
54+ buttonStyle: lipgloss.NewStyle().
55+ Padding(0, 2).
56+ Foreground(styles.ColorText),
57+ focusedStyle: lipgloss.NewStyle().
58+ Padding(0, 2).
59+ Bold(true).
60+ Foreground(styles.ColorBg).
61+ Background(styles.ColorPrimary),
62+ }
63+
64+ for _, opt := range opts {
65+ opt(&m)
66+ }
67+
68+ return m
69+}
70+
71+// WithDescription adds a description to the confirmation.
72+func WithDescription(desc string) ConfirmOption {
73+ return func(m *ConfirmModel) {
74+ m.description = desc
75+ }
76+}
77+
78+// WithDefaultYes sets Yes as the default selection.
79+func WithDefaultYes() ConfirmOption {
80+ return func(m *ConfirmModel) {
81+ m.focused = true
82+ }
83+}
84+
85+// WithLabels customizes the Yes/No labels.
86+func WithLabels(yes, no string) ConfirmOption {
87+ return func(m *ConfirmModel) {
88+ m.yesLabel = yes
89+ m.noLabel = no
90+ }
91+}
92+
93+// WithWidth sets the dialog width.
94+func WithWidth(w int) ConfirmOption {
95+ return func(m *ConfirmModel) {
96+ m.width = w
97+ }
98+}
99+
100+// Init implements tea.Model.
101+func (m ConfirmModel) Init() tea.Cmd {
102+ return nil
103+}
104+
105+// Update implements tea.Model.
106+func (m ConfirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107+ switch msg := msg.(type) {
108+ case tea.KeyMsg:
109+ switch msg.String() {
110+ case "left", "h", "tab", "shift+tab":
111+ m.focused = !m.focused
112+ case "right", "l":
113+ m.focused = !m.focused
114+ case "y", "Y":
115+ m.result = ConfirmYes
116+ return m, tea.Quit
117+ case "n", "N":
118+ m.result = ConfirmNo
119+ return m, tea.Quit
120+ case "enter":
121+ if m.focused {
122+ m.result = ConfirmYes
123+ } else {
124+ m.result = ConfirmNo
125+ }
126+ return m, tea.Quit
127+ case "esc", "ctrl+c", "q":
128+ m.result = ConfirmCancelled
129+ return m, tea.Quit
130+ }
131+ }
132+ return m, nil
133+}
134+
135+// View implements tea.Model.
136+func (m ConfirmModel) View() string {
137+ var b strings.Builder
138+
139+ // Question
140+ b.WriteString(m.questionStyle.Render(m.question))
141+ b.WriteString("\n")
142+
143+ // Description
144+ if m.description != "" {
145+ b.WriteString(m.descriptionStyle.Render(m.description))
146+ b.WriteString("\n")
147+ }
148+ b.WriteString("\n")
149+
150+ // Buttons
151+ var yesButton, noButton string
152+ if m.focused {
153+ yesButton = m.focusedStyle.Render(m.yesLabel)
154+ noButton = m.buttonStyle.Render(m.noLabel)
155+ } else {
156+ yesButton = m.buttonStyle.Render(m.yesLabel)
157+ noButton = m.focusedStyle.Render(m.noLabel)
158+ }
159+
160+ buttons := lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton)
161+ b.WriteString(buttons)
162+ b.WriteString("\n\n")
163+
164+ // Help
165+ help := styles.RenderKeyHelp("y/n", "select") + " " +
166+ styles.RenderKeyHelp("enter", "confirm") + " " +
167+ styles.RenderKeyHelp("esc", "cancel")
168+ b.WriteString(help)
169+
170+ return b.String()
171+}
172+
173+// Result returns the confirmation result.
174+func (m ConfirmModel) Result() ConfirmResult {
175+ return m.result
176+}
177+
178+// Confirmed returns true if the user confirmed.
179+func (m ConfirmModel) Confirmed() bool {
180+ return m.result == ConfirmYes
181+}
182+
183+// Cancelled returns true if the user cancelled.
184+func (m ConfirmModel) Cancelled() bool {
185+ return m.result == ConfirmCancelled
186+}
187+
188+// RunConfirm runs a confirmation dialog and returns the result.
189+// This is a convenience function for simple confirmations.
190+func RunConfirm(question string, opts ...ConfirmOption) (bool, error) {
191+ m := NewConfirm(question, opts...)
192+ p := tea.NewProgram(m)
193+ finalModel, err := p.Run()
194+ if err != nil {
195+ return false, fmt.Errorf("error running confirmation: %w", err)
196+ }
197+ result := finalModel.(ConfirmModel)
198+ if result.Cancelled() {
199+ return false, nil
200+ }
201+ return result.Confirmed(), nil
202+}
A internal/adapters/in/cli/ui/components/form.go +358 -0
@@ -0,0 +1,358 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+)
13+
14+// FieldType defines the type of form field.
15+type FieldType int
16+
17+const (
18+ FieldTypeText FieldType = iota
19+ FieldTypePassword
20+ FieldTypeSelect
21+)
22+
23+// FormField defines a form field configuration.
24+type FormField struct {
25+ Key string
26+ Label string
27+ Placeholder string
28+ Default string
29+ Required bool
30+ Type FieldType
31+ Options []string // For select fields
32+ Validator func(string) error
33+}
34+
35+// FormModel is an interactive form component.
36+type FormModel struct {
37+ title string
38+ description string
39+ fields []FormField
40+ inputs []textinput.Model
41+ focused int
42+ submitted bool
43+ cancelled bool
44+ errors []string
45+ width int
46+
47+ // Styles
48+ titleStyle lipgloss.Style
49+ descriptionStyle lipgloss.Style
50+ labelStyle lipgloss.Style
51+ errorStyle lipgloss.Style
52+}
53+
54+// FormOption configures a FormModel.
55+type FormOption func(*FormModel)
56+
57+// NewForm creates a new form with the given fields.
58+func NewForm(fields []FormField, opts ...FormOption) FormModel {
59+ inputs := make([]textinput.Model, len(fields))
60+ errors := make([]string, len(fields))
61+
62+ for i, field := range fields {
63+ ti := textinput.New()
64+ ti.Placeholder = field.Placeholder
65+ ti.SetValue(field.Default)
66+ ti.CharLimit = 256
67+ ti.Width = 40
68+
69+ if field.Type == FieldTypePassword {
70+ ti.EchoMode = textinput.EchoPassword
71+ ti.EchoCharacter = '*'
72+ }
73+
74+ if i == 0 {
75+ ti.Focus()
76+ }
77+
78+ inputs[i] = ti
79+ }
80+
81+ m := FormModel{
82+ fields: fields,
83+ inputs: inputs,
84+ focused: 0,
85+ errors: errors,
86+ width: 50,
87+ titleStyle: styles.Theme.Title,
88+ descriptionStyle: styles.Theme.Muted,
89+ labelStyle: styles.Theme.FormLabel,
90+ errorStyle: styles.Theme.FormError,
91+ }
92+
93+ for _, opt := range opts {
94+ opt(&m)
95+ }
96+
97+ return m
98+}
99+
100+// WithFormTitle sets the form title.
101+func WithFormTitle(title string) FormOption {
102+ return func(m *FormModel) {
103+ m.title = title
104+ }
105+}
106+
107+// WithFormDescription sets the form description.
108+func WithFormDescription(desc string) FormOption {
109+ return func(m *FormModel) {
110+ m.description = desc
111+ }
112+}
113+
114+// WithFormWidth sets the form width.
115+func WithFormWidth(w int) FormOption {
116+ return func(m *FormModel) {
117+ m.width = w
118+ for i := range m.inputs {
119+ m.inputs[i].Width = w - 4 // Account for padding
120+ }
121+ }
122+}
123+
124+// Init implements tea.Model.
125+func (m FormModel) Init() tea.Cmd {
126+ return textinput.Blink
127+}
128+
129+// Update implements tea.Model.
130+func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131+ var cmds []tea.Cmd
132+
133+ switch msg := msg.(type) {
134+ case tea.KeyMsg:
135+ switch msg.String() {
136+ case "ctrl+c", "esc":
137+ m.cancelled = true
138+ return m, tea.Quit
139+ case "tab", "down":
140+ m.focusNext()
141+ case "shift+tab", "up":
142+ m.focusPrev()
143+ case "enter":
144+ if m.focused == len(m.fields)-1 {
145+ // On last field, submit
146+ if m.validate() {
147+ m.submitted = true
148+ return m, tea.Quit
149+ }
150+ } else {
151+ m.focusNext()
152+ }
153+ }
154+ }
155+
156+ // Update the focused input
157+ var cmd tea.Cmd
158+ m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
159+ cmds = append(cmds, cmd)
160+
161+ return m, tea.Batch(cmds...)
162+}
163+
164+// focusNext moves focus to the next field.
165+func (m *FormModel) focusNext() {
166+ m.inputs[m.focused].Blur()
167+ m.focused = (m.focused + 1) % len(m.fields)
168+ m.inputs[m.focused].Focus()
169+}
170+
171+// focusPrev moves focus to the previous field.
172+func (m *FormModel) focusPrev() {
173+ m.inputs[m.focused].Blur()
174+ m.focused--
175+ if m.focused < 0 {
176+ m.focused = len(m.fields) - 1
177+ }
178+ m.inputs[m.focused].Focus()
179+}
180+
181+// validate validates all fields.
182+func (m *FormModel) validate() bool {
183+ valid := true
184+ for i, field := range m.fields {
185+ value := m.inputs[i].Value()
186+ m.errors[i] = ""
187+
188+ // Check required
189+ if field.Required && strings.TrimSpace(value) == "" {
190+ m.errors[i] = "This field is required"
191+ valid = false
192+ continue
193+ }
194+
195+ // Run custom validator
196+ if field.Validator != nil && value != "" {
197+ if err := field.Validator(value); err != nil {
198+ m.errors[i] = err.Error()
199+ valid = false
200+ }
201+ }
202+ }
203+ return valid
204+}
205+
206+// View implements tea.Model.
207+func (m FormModel) View() string {
208+ var b strings.Builder
209+
210+ // Title
211+ if m.title != "" {
212+ b.WriteString(m.titleStyle.Render(m.title))
213+ b.WriteString("\n")
214+ }
215+
216+ // Description
217+ if m.description != "" {
218+ b.WriteString(m.descriptionStyle.Render(m.description))
219+ b.WriteString("\n")
220+ }
221+
222+ if m.title != "" || m.description != "" {
223+ b.WriteString("\n")
224+ }
225+
226+ // Fields
227+ for i, field := range m.fields {
228+ // Label
229+ label := field.Label
230+ if field.Required {
231+ label += " *"
232+ }
233+ b.WriteString(m.labelStyle.Render(label))
234+ b.WriteString("\n")
235+
236+ // Input
237+ b.WriteString(m.inputs[i].View())
238+ b.WriteString("\n")
239+
240+ // Error
241+ if m.errors[i] != "" {
242+ b.WriteString(m.errorStyle.Render(styles.IconError + " " + m.errors[i]))
243+ b.WriteString("\n")
244+ }
245+
246+ b.WriteString("\n")
247+ }
248+
249+ // Help
250+ help := styles.RenderKeyHelp("tab/shift+tab", "navigate") + " " +
251+ styles.RenderKeyHelp("enter", "submit") + " " +
252+ styles.RenderKeyHelp("esc", "cancel")
253+ b.WriteString(help)
254+
255+ return b.String()
256+}
257+
258+// Values returns the form values as a map.
259+func (m FormModel) Values() map[string]string {
260+ values := make(map[string]string)
261+ for i, field := range m.fields {
262+ values[field.Key] = m.inputs[i].Value()
263+ }
264+ return values
265+}
266+
267+// Value returns the value for a specific field.
268+func (m FormModel) Value(key string) string {
269+ for i, field := range m.fields {
270+ if field.Key == key {
271+ return m.inputs[i].Value()
272+ }
273+ }
274+ return ""
275+}
276+
277+// Submitted returns true if the form was submitted.
278+func (m FormModel) Submitted() bool {
279+ return m.submitted
280+}
281+
282+// Cancelled returns true if the form was cancelled.
283+func (m FormModel) Cancelled() bool {
284+ return m.cancelled
285+}
286+
287+// RunForm runs a form and returns the values.
288+// This is a convenience function for simple forms.
289+func RunForm(title string, fields []FormField) (map[string]string, error) {
290+ m := NewForm(fields, WithFormTitle(title))
291+ p := tea.NewProgram(m)
292+ finalModel, err := p.Run()
293+ if err != nil {
294+ return nil, fmt.Errorf("error running form: %w", err)
295+ }
296+ result := finalModel.(FormModel)
297+ if result.Cancelled() {
298+ return nil, nil
299+ }
300+ if !result.Submitted() {
301+ return nil, nil
302+ }
303+ return result.Values(), nil
304+}
305+
306+// TextField creates a text field configuration.
307+func TextField(key, label string, opts ...func(*FormField)) FormField {
308+ f := FormField{
309+ Key: key,
310+ Label: label,
311+ Type: FieldTypeText,
312+ }
313+ for _, opt := range opts {
314+ opt(&f)
315+ }
316+ return f
317+}
318+
319+// PasswordField creates a password field configuration.
320+func PasswordField(key, label string, opts ...func(*FormField)) FormField {
321+ f := FormField{
322+ Key: key,
323+ Label: label,
324+ Type: FieldTypePassword,
325+ }
326+ for _, opt := range opts {
327+ opt(&f)
328+ }
329+ return f
330+}
331+
332+// Required marks a field as required.
333+func Required() func(*FormField) {
334+ return func(f *FormField) {
335+ f.Required = true
336+ }
337+}
338+
339+// WithPlaceholder sets the field placeholder.
340+func WithPlaceholder(p string) func(*FormField) {
341+ return func(f *FormField) {
342+ f.Placeholder = p
343+ }
344+}
345+
346+// WithDefault sets the field default value.
347+func WithDefault(d string) func(*FormField) {
348+ return func(f *FormField) {
349+ f.Default = d
350+ }
351+}
352+
353+// WithValidator sets the field validator.
354+func WithValidator(v func(string) error) func(*FormField) {
355+ return func(f *FormField) {
356+ f.Validator = v
357+ }
358+}
@@ -0,0 +1,358 @@
1+package components
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "gordon/internal/adapters/in/cli/ui/styles"
8+
9+ "github.com/charmbracelet/bubbles/textinput"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+)
13+
14+// FieldType defines the type of form field.
15+type FieldType int
16+
17+const (
18+ FieldTypeText FieldType = iota
19+ FieldTypePassword
20+ FieldTypeSelect
21+)
22+
23+// FormField defines a form field configuration.
24+type FormField struct {
25+ Key string
26+ Label string
27+ Placeholder string
28+ Default string
29+ Required bool
30+ Type FieldType
31+ Options []string // For select fields
32+ Validator func(string) error
33+}
34+
35+// FormModel is an interactive form component.
36+type FormModel struct {
37+ title string
38+ description string
39+ fields []FormField
40+ inputs []textinput.Model
41+ focused int
42+ submitted bool
43+ cancelled bool
44+ errors []string
45+ width int
46+
47+ // Styles
48+ titleStyle lipgloss.Style
49+ descriptionStyle lipgloss.Style
50+ labelStyle lipgloss.Style
51+ errorStyle lipgloss.Style
52+}
53+
54+// FormOption configures a FormModel.
55+type FormOption func(*FormModel)
56+
57+// NewForm creates a new form with the given fields.
58+func NewForm(fields []FormField, opts ...FormOption) FormModel {
59+ inputs := make([]textinput.Model, len(fields))
60+ errors := make([]string, len(fields))
61+
62+ for i, field := range fields {
63+ ti := textinput.New()
64+ ti.Placeholder = field.Placeholder
65+ ti.SetValue(field.Default)
66+ ti.CharLimit = 256
67+ ti.Width = 40
68+
69+ if field.Type == FieldTypePassword {
70+ ti.EchoMode = textinput.EchoPassword
71+ ti.EchoCharacter = '*'
72+ }
73+
74+ if i == 0 {
75+ ti.Focus()
76+ }
77+
78+ inputs[i] = ti
79+ }
80+
81+ m := FormModel{
82+ fields: fields,
83+ inputs: inputs,
84+ focused: 0,
85+ errors: errors,
86+ width: 50,
87+ titleStyle: styles.Theme.Title,
88+ descriptionStyle: styles.Theme.Muted,
89+ labelStyle: styles.Theme.FormLabel,
90+ errorStyle: styles.Theme.FormError,
91+ }
92+
93+ for _, opt := range opts {
94+ opt(&m)
95+ }
96+
97+ return m
98+}
99+
100+// WithFormTitle sets the form title.
101+func WithFormTitle(title string) FormOption {
102+ return func(m *FormModel) {
103+ m.title = title
104+ }
105+}
106+
107+// WithFormDescription sets the form description.
108+func WithFormDescription(desc string) FormOption {
109+ return func(m *FormModel) {
110+ m.description = desc
111+ }
112+}
113+
114+// WithFormWidth sets the form width.
115+func WithFormWidth(w int) FormOption {
116+ return func(m *FormModel) {
117+ m.width = w
118+ for i := range m.inputs {
119+ m.inputs[i].Width = w - 4 // Account for padding
120+ }
121+ }
122+}
123+
124+// Init implements tea.Model.
125+func (m FormModel) Init() tea.Cmd {
126+ return textinput.Blink
127+}
128+
129+// Update implements tea.Model.
130+func (m FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
131+ var cmds []tea.Cmd
132+
133+ switch msg := msg.(type) {
134+ case tea.KeyMsg:
135+ switch msg.String() {
136+ case "ctrl+c", "esc":
137+ m.cancelled = true
138+ return m, tea.Quit
139+ case "tab", "down":
140+ m.focusNext()
141+ case "shift+tab", "up":
142+ m.focusPrev()
143+ case "enter":
144+ if m.focused == len(m.fields)-1 {
145+ // On last field, submit
146+ if m.validate() {
147+ m.submitted = true
148+ return m, tea.Quit
149+ }
150+ } else {
151+ m.focusNext()
152+ }
153+ }
154+ }
155+
156+ // Update the focused input
157+ var cmd tea.Cmd
158+ m.inputs[m.focused], cmd = m.inputs[m.focused].Update(msg)
159+ cmds = append(cmds, cmd)
160+
161+ return m, tea.Batch(cmds...)
162+}
163+
164+// focusNext moves focus to the next field.
165+func (m *FormModel) focusNext() {
166+ m.inputs[m.focused].Blur()
167+ m.focused = (m.focused + 1) % len(m.fields)
168+ m.inputs[m.focused].Focus()
169+}
170+
171+// focusPrev moves focus to the previous field.
172+func (m *FormModel) focusPrev() {
173+ m.inputs[m.focused].Blur()
174+ m.focused--
175+ if m.focused < 0 {
176+ m.focused = len(m.fields) - 1
177+ }
178+ m.inputs[m.focused].Focus()
179+}
180+
181+// validate validates all fields.
182+func (m *FormModel) validate() bool {
183+ valid := true
184+ for i, field := range m.fields {
185+ value := m.inputs[i].Value()
186+ m.errors[i] = ""
187+
188+ // Check required
189+ if field.Required && strings.TrimSpace(value) == "" {
190+ m.errors[i] = "This field is required"
191+ valid = false
192+ continue
193+ }
194+
195+ // Run custom validator
196+ if field.Validator != nil && value != "" {
197+ if err := field.Validator(value); err != nil {
198+ m.errors[i] = err.Error()
199+ valid = false
200+ }
201+ }
202+ }
203+ return valid
204+}
205+
206+// View implements tea.Model.
207+func (m FormModel) View() string {
208+ var b strings.Builder
209+
210+ // Title
211+ if m.title != "" {
212+ b.WriteString(m.titleStyle.Render(m.title))
213+ b.WriteString("\n")
214+ }
215+
216+ // Description
217+ if m.description != "" {
218+ b.WriteString(m.descriptionStyle.Render(m.description))
219+ b.WriteString("\n")
220+ }
221+
222+ if m.title != "" || m.description != "" {
223+ b.WriteString("\n")
224+ }
225+
226+ // Fields
227+ for i, field := range m.fields {
228+ // Label
229+ label := field.Label
230+ if field.Required {
231+ label += " *"
232+ }
233+ b.WriteString(m.labelStyle.Render(label))
234+ b.WriteString("\n")
235+
236+ // Input
237+ b.WriteString(m.inputs[i].View())
238+ b.WriteString("\n")
239+
240+ // Error
241+ if m.errors[i] != "" {
242+ b.WriteString(m.errorStyle.Render(styles.IconError + " " + m.errors[i]))
243+ b.WriteString("\n")
244+ }
245+
246+ b.WriteString("\n")
247+ }
248+
249+ // Help
250+ help := styles.RenderKeyHelp("tab/shift+tab", "navigate") + " " +
251+ styles.RenderKeyHelp("enter", "submit") + " " +
252+ styles.RenderKeyHelp("esc", "cancel")
253+ b.WriteString(help)
254+
255+ return b.String()
256+}
257+
258+// Values returns the form values as a map.
259+func (m FormModel) Values() map[string]string {
260+ values := make(map[string]string)
261+ for i, field := range m.fields {
262+ values[field.Key] = m.inputs[i].Value()
263+ }
264+ return values
265+}
266+
267+// Value returns the value for a specific field.
268+func (m FormModel) Value(key string) string {
269+ for i, field := range m.fields {
270+ if field.Key == key {
271+ return m.inputs[i].Value()
272+ }
273+ }
274+ return ""
275+}
276+
277+// Submitted returns true if the form was submitted.
278+func (m FormModel) Submitted() bool {
279+ return m.submitted
280+}
281+
282+// Cancelled returns true if the form was cancelled.
283+func (m FormModel) Cancelled() bool {
284+ return m.cancelled
285+}
286+
287+// RunForm runs a form and returns the values.
288+// This is a convenience function for simple forms.
289+func RunForm(title string, fields []FormField) (map[string]string, error) {
290+ m := NewForm(fields, WithFormTitle(title))
291+ p := tea.NewProgram(m)
292+ finalModel, err := p.Run()
293+ if err != nil {
294+ return nil, fmt.Errorf("error running form: %w", err)
295+ }
296+ result := finalModel.(FormModel)
297+ if result.Cancelled() {
298+ return nil, nil
299+ }
300+ if !result.Submitted() {
301+ return nil, nil
302+ }
303+ return result.Values(), nil
304+}
305+
306+// TextField creates a text field configuration.
307+func TextField(key, label string, opts ...func(*FormField)) FormField {
308+ f := FormField{
309+ Key: key,
310+ Label: label,
311+ Type: FieldTypeText,
312+ }
313+ for _, opt := range opts {
314+ opt(&f)
315+ }
316+ return f
317+}
318+
319+// PasswordField creates a password field configuration.
320+func PasswordField(key, label string, opts ...func(*FormField)) FormField {
321+ f := FormField{
322+ Key: key,
323+ Label: label,
324+ Type: FieldTypePassword,
325+ }
326+ for _, opt := range opts {
327+ opt(&f)
328+ }
329+ return f
330+}
331+
332+// Required marks a field as required.
333+func Required() func(*FormField) {
334+ return func(f *FormField) {
335+ f.Required = true
336+ }
337+}
338+
339+// WithPlaceholder sets the field placeholder.
340+func WithPlaceholder(p string) func(*FormField) {
341+ return func(f *FormField) {
342+ f.Placeholder = p
343+ }
344+}
345+
346+// WithDefault sets the field default value.
347+func WithDefault(d string) func(*FormField) {
348+ return func(f *FormField) {
349+ f.Default = d
350+ }
351+}
352+
353+// WithValidator sets the field validator.
354+func WithValidator(v func(string) error) func(*FormField) {
355+ return func(f *FormField) {
356+ f.Validator = v
357+ }
358+}
A internal/adapters/in/cli/ui/components/spinner.go +117 -0
@@ -0,0 +1,117 @@
1+// Package components provides reusable TUI components for Gordon's CLI.
2+package components
3+
4+import (
5+ "gordon/internal/adapters/in/cli/ui/styles"
6+
7+ "github.com/charmbracelet/bubbles/spinner"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+)
11+
12+// SpinnerModel wraps the bubbles spinner with Gordon styling.
13+type SpinnerModel struct {
14+ spinner spinner.Model
15+ message string
16+ style lipgloss.Style
17+}
18+
19+// SpinnerOption configures a SpinnerModel.
20+type SpinnerOption func(*SpinnerModel)
21+
22+// NewSpinner creates a new spinner with Gordon styling.
23+func NewSpinner(opts ...SpinnerOption) SpinnerModel {
24+ s := spinner.New()
25+ s.Spinner = spinner.Dot
26+ s.Style = lipgloss.NewStyle().Foreground(styles.ColorPrimary)
27+
28+ m := SpinnerModel{
29+ spinner: s,
30+ message: "Loading...",
31+ style: styles.Theme.Body,
32+ }
33+
34+ for _, opt := range opts {
35+ opt(&m)
36+ }
37+
38+ return m
39+}
40+
41+// WithMessage sets the spinner message.
42+func WithMessage(msg string) SpinnerOption {
43+ return func(m *SpinnerModel) {
44+ m.message = msg
45+ }
46+}
47+
48+// WithSpinnerType sets the spinner animation type.
49+func WithSpinnerType(t spinner.Spinner) SpinnerOption {
50+ return func(m *SpinnerModel) {
51+ m.spinner.Spinner = t
52+ }
53+}
54+
55+// WithSpinnerColor sets the spinner color.
56+func WithSpinnerColor(color lipgloss.Color) SpinnerOption {
57+ return func(m *SpinnerModel) {
58+ m.spinner.Style = lipgloss.NewStyle().Foreground(color)
59+ }
60+}
61+
62+// Init implements tea.Model.
63+func (m SpinnerModel) Init() tea.Cmd {
64+ return m.spinner.Tick
65+}
66+
67+// Update implements tea.Model.
68+func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
69+ var cmd tea.Cmd
70+ m.spinner, cmd = m.spinner.Update(msg)
71+ return m, cmd
72+}
73+
74+// View implements tea.Model.
75+func (m SpinnerModel) View() string {
76+ return m.spinner.View() + " " + m.style.Render(m.message)
77+}
78+
79+// SetMessage updates the spinner message.
80+func (m *SpinnerModel) SetMessage(msg string) {
81+ m.message = msg
82+}
83+
84+// Tick returns the spinner tick command for animation.
85+func (m SpinnerModel) Tick() tea.Cmd {
86+ return m.spinner.Tick
87+}
88+
89+// Common spinner types with Gordon colors.
90+var (
91+ // SpinnerDot is the default dot spinner.
92+ SpinnerDot = spinner.Dot
93+
94+ // SpinnerLine is a simple line spinner.
95+ SpinnerLine = spinner.Line
96+
97+ // SpinnerMiniDot is a smaller dot spinner.
98+ SpinnerMiniDot = spinner.MiniDot
99+
100+ // SpinnerJump is a jumping spinner.
101+ SpinnerJump = spinner.Jump
102+
103+ // SpinnerPulse is a pulsing spinner.
104+ SpinnerPulse = spinner.Pulse
105+
106+ // SpinnerPoints is a points spinner.
107+ SpinnerPoints = spinner.Points
108+
109+ // SpinnerGlobe is a globe spinner.
110+ SpinnerGlobe = spinner.Globe
111+
112+ // SpinnerMoon is a moon phase spinner.
113+ SpinnerMoon = spinner.Moon
114+
115+ // SpinnerMonkey is a monkey spinner.
116+ SpinnerMonkey = spinner.Monkey
117+)
@@ -0,0 +1,117 @@
1+// Package components provides reusable TUI components for Gordon's CLI.
2+package components
3+
4+import (
5+ "gordon/internal/adapters/in/cli/ui/styles"
6+
7+ "github.com/charmbracelet/bubbles/spinner"
8+ tea "github.com/charmbracelet/bubbletea"
9+ "github.com/charmbracelet/lipgloss"
10+)
11+
12+// SpinnerModel wraps the bubbles spinner with Gordon styling.
13+type SpinnerModel struct {
14+ spinner spinner.Model
15+ message string
16+ style lipgloss.Style
17+}
18+
19+// SpinnerOption configures a SpinnerModel.
20+type SpinnerOption func(*SpinnerModel)
21+
22+// NewSpinner creates a new spinner with Gordon styling.
23+func NewSpinner(opts ...SpinnerOption) SpinnerModel {
24+ s := spinner.New()
25+ s.Spinner = spinner.Dot
26+ s.Style = lipgloss.NewStyle().Foreground(styles.ColorPrimary)
27+
28+ m := SpinnerModel{
29+ spinner: s,
30+ message: "Loading...",
31+ style: styles.Theme.Body,
32+ }
33+
34+ for _, opt := range opts {
35+ opt(&m)
36+ }
37+
38+ return m
39+}
40+
41+// WithMessage sets the spinner message.
42+func WithMessage(msg string) SpinnerOption {
43+ return func(m *SpinnerModel) {
44+ m.message = msg
45+ }
46+}
47+
48+// WithSpinnerType sets the spinner animation type.
49+func WithSpinnerType(t spinner.Spinner) SpinnerOption {
50+ return func(m *SpinnerModel) {
51+ m.spinner.Spinner = t
52+ }
53+}
54+
55+// WithSpinnerColor sets the spinner color.
56+func WithSpinnerColor(color lipgloss.Color) SpinnerOption {
57+ return func(m *SpinnerModel) {
58+ m.spinner.Style = lipgloss.NewStyle().Foreground(color)
59+ }
60+}
61+
62+// Init implements tea.Model.
63+func (m SpinnerModel) Init() tea.Cmd {
64+ return m.spinner.Tick
65+}
66+
67+// Update implements tea.Model.
68+func (m SpinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
69+ var cmd tea.Cmd
70+ m.spinner, cmd = m.spinner.Update(msg)
71+ return m, cmd
72+}
73+
74+// View implements tea.Model.
75+func (m SpinnerModel) View() string {
76+ return m.spinner.View() + " " + m.style.Render(m.message)
77+}
78+
79+// SetMessage updates the spinner message.
80+func (m *SpinnerModel) SetMessage(msg string) {
81+ m.message = msg
82+}
83+
84+// Tick returns the spinner tick command for animation.
85+func (m SpinnerModel) Tick() tea.Cmd {
86+ return m.spinner.Tick
87+}
88+
89+// Common spinner types with Gordon colors.
90+var (
91+ // SpinnerDot is the default dot spinner.
92+ SpinnerDot = spinner.Dot
93+
94+ // SpinnerLine is a simple line spinner.
95+ SpinnerLine = spinner.Line
96+
97+ // SpinnerMiniDot is a smaller dot spinner.
98+ SpinnerMiniDot = spinner.MiniDot
99+
100+ // SpinnerJump is a jumping spinner.
101+ SpinnerJump = spinner.Jump
102+
103+ // SpinnerPulse is a pulsing spinner.
104+ SpinnerPulse = spinner.Pulse
105+
106+ // SpinnerPoints is a points spinner.
107+ SpinnerPoints = spinner.Points
108+
109+ // SpinnerGlobe is a globe spinner.
110+ SpinnerGlobe = spinner.Globe
111+
112+ // SpinnerMoon is a moon phase spinner.
113+ SpinnerMoon = spinner.Moon
114+
115+ // SpinnerMonkey is a monkey spinner.
116+ SpinnerMonkey = spinner.Monkey
117+)
A internal/adapters/in/cli/ui/components/status.go +158 -0
@@ -0,0 +1,158 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "gordon/internal/adapters/in/cli/ui/styles"
7+
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// Status represents a status type for rendering.
12+type Status int
13+
14+const (
15+ StatusSuccess Status = iota
16+ StatusError
17+ StatusWarning
18+ StatusInfo
19+ StatusPending
20+ StatusRunning
21+ StatusStopped
22+)
23+
24+// StatusConfig holds configuration for status rendering.
25+type StatusConfig struct {
26+ Status Status
27+ Label string
28+ Icon string
29+ Style lipgloss.Style
30+ Badge bool // If true, render as a badge with background
31+ Compact bool // If true, render icon only
32+}
33+
34+// DefaultStatusConfigs provides default configurations for each status type.
35+var DefaultStatusConfigs = map[Status]StatusConfig{
36+ StatusSuccess: {
37+ Icon: styles.IconSuccess,
38+ Style: styles.Theme.Success,
39+ },
40+ StatusError: {
41+ Icon: styles.IconError,
42+ Style: styles.Theme.Error,
43+ },
44+ StatusWarning: {
45+ Icon: styles.IconWarning,
46+ Style: styles.Theme.Warning,
47+ },
48+ StatusInfo: {
49+ Icon: styles.IconInfo,
50+ Style: styles.Theme.Info,
51+ },
52+ StatusPending: {
53+ Icon: styles.IconPending,
54+ Style: styles.Theme.Muted,
55+ },
56+ StatusRunning: {
57+ Icon: styles.IconSuccess,
58+ Style: styles.Theme.Success,
59+ },
60+ StatusStopped: {
61+ Icon: styles.IconError,
62+ Style: styles.Theme.Error,
63+ },
64+}
65+
66+// RenderStatus renders a status with icon and optional label.
67+func RenderStatus(status Status, label string) string {
68+ config := DefaultStatusConfigs[status]
69+ if label == "" {
70+ return config.Style.Render(config.Icon)
71+ }
72+ return config.Style.Render(config.Icon + " " + label)
73+}
74+
75+// RenderStatusBadge renders a status as a badge with background.
76+func RenderStatusBadge(status Status, label string) string {
77+ var badgeStyle lipgloss.Style
78+ switch status {
79+ case StatusSuccess, StatusRunning:
80+ badgeStyle = styles.Theme.BadgeSuccess
81+ case StatusError, StatusStopped:
82+ badgeStyle = styles.Theme.BadgeError
83+ case StatusWarning:
84+ badgeStyle = styles.Theme.BadgeWarning
85+ case StatusPending:
86+ badgeStyle = styles.Theme.BadgePending
87+ default:
88+ badgeStyle = styles.Theme.BadgeInfo
89+ }
90+ return badgeStyle.Render(label)
91+}
92+
93+// ParseStatus converts a string status to Status type.
94+func ParseStatus(s string) Status {
95+ switch strings.ToLower(s) {
96+ case "success", "ok", "active", "healthy":
97+ return StatusSuccess
98+ case "error", "failed", "unhealthy":
99+ return StatusError
100+ case "warning", "degraded":
101+ return StatusWarning
102+ case "info":
103+ return StatusInfo
104+ case "pending", "starting", "waiting":
105+ return StatusPending
106+ case "running", "up":
107+ return StatusRunning
108+ case "stopped", "down", "exited":
109+ return StatusStopped
110+ default:
111+ return StatusInfo
112+ }
113+}
114+
115+// StatusIndicator is a simple status indicator component.
116+type StatusIndicator struct {
117+ Status Status
118+ Label string
119+ Badge bool
120+}
121+
122+// NewStatusIndicator creates a new status indicator.
123+func NewStatusIndicator(status Status, label string) StatusIndicator {
124+ return StatusIndicator{
125+ Status: status,
126+ Label: label,
127+ Badge: false,
128+ }
129+}
130+
131+// NewStatusBadge creates a new status badge (with background).
132+func NewStatusBadge(status Status, label string) StatusIndicator {
133+ return StatusIndicator{
134+ Status: status,
135+ Label: label,
136+ Badge: true,
137+ }
138+}
139+
140+// View renders the status indicator.
141+func (s StatusIndicator) View() string {
142+ if s.Badge {
143+ return RenderStatusBadge(s.Status, s.Label)
144+ }
145+ return RenderStatus(s.Status, s.Label)
146+}
147+
148+// ContainerStatusIndicator renders container status with appropriate styling.
149+func ContainerStatusIndicator(status string) string {
150+ parsed := ParseStatus(status)
151+ return RenderStatus(parsed, status)
152+}
153+
154+// ContainerStatusBadge renders container status as a badge.
155+func ContainerStatusBadge(status string) string {
156+ parsed := ParseStatus(status)
157+ return RenderStatusBadge(parsed, status)
158+}
@@ -0,0 +1,158 @@
1+package components
2+
3+import (
4+ "strings"
5+
6+ "gordon/internal/adapters/in/cli/ui/styles"
7+
8+ "github.com/charmbracelet/lipgloss"
9+)
10+
11+// Status represents a status type for rendering.
12+type Status int
13+
14+const (
15+ StatusSuccess Status = iota
16+ StatusError
17+ StatusWarning
18+ StatusInfo
19+ StatusPending
20+ StatusRunning
21+ StatusStopped
22+)
23+
24+// StatusConfig holds configuration for status rendering.
25+type StatusConfig struct {
26+ Status Status
27+ Label string
28+ Icon string
29+ Style lipgloss.Style
30+ Badge bool // If true, render as a badge with background
31+ Compact bool // If true, render icon only
32+}
33+
34+// DefaultStatusConfigs provides default configurations for each status type.
35+var DefaultStatusConfigs = map[Status]StatusConfig{
36+ StatusSuccess: {
37+ Icon: styles.IconSuccess,
38+ Style: styles.Theme.Success,
39+ },
40+ StatusError: {
41+ Icon: styles.IconError,
42+ Style: styles.Theme.Error,
43+ },
44+ StatusWarning: {
45+ Icon: styles.IconWarning,
46+ Style: styles.Theme.Warning,
47+ },
48+ StatusInfo: {
49+ Icon: styles.IconInfo,
50+ Style: styles.Theme.Info,
51+ },
52+ StatusPending: {
53+ Icon: styles.IconPending,
54+ Style: styles.Theme.Muted,
55+ },
56+ StatusRunning: {
57+ Icon: styles.IconSuccess,
58+ Style: styles.Theme.Success,
59+ },
60+ StatusStopped: {
61+ Icon: styles.IconError,
62+ Style: styles.Theme.Error,
63+ },
64+}
65+
66+// RenderStatus renders a status with icon and optional label.
67+func RenderStatus(status Status, label string) string {
68+ config := DefaultStatusConfigs[status]
69+ if label == "" {
70+ return config.Style.Render(config.Icon)
71+ }
72+ return config.Style.Render(config.Icon + " " + label)
73+}
74+
75+// RenderStatusBadge renders a status as a badge with background.
76+func RenderStatusBadge(status Status, label string) string {
77+ var badgeStyle lipgloss.Style
78+ switch status {
79+ case StatusSuccess, StatusRunning:
80+ badgeStyle = styles.Theme.BadgeSuccess
81+ case StatusError, StatusStopped:
82+ badgeStyle = styles.Theme.BadgeError
83+ case StatusWarning:
84+ badgeStyle = styles.Theme.BadgeWarning
85+ case StatusPending:
86+ badgeStyle = styles.Theme.BadgePending
87+ default:
88+ badgeStyle = styles.Theme.BadgeInfo
89+ }
90+ return badgeStyle.Render(label)
91+}
92+
93+// ParseStatus converts a string status to Status type.
94+func ParseStatus(s string) Status {
95+ switch strings.ToLower(s) {
96+ case "success", "ok", "active", "healthy":
97+ return StatusSuccess
98+ case "error", "failed", "unhealthy":
99+ return StatusError
100+ case "warning", "degraded":
101+ return StatusWarning
102+ case "info":
103+ return StatusInfo
104+ case "pending", "starting", "waiting":
105+ return StatusPending
106+ case "running", "up":
107+ return StatusRunning
108+ case "stopped", "down", "exited":
109+ return StatusStopped
110+ default:
111+ return StatusInfo
112+ }
113+}
114+
115+// StatusIndicator is a simple status indicator component.
116+type StatusIndicator struct {
117+ Status Status
118+ Label string
119+ Badge bool
120+}
121+
122+// NewStatusIndicator creates a new status indicator.
123+func NewStatusIndicator(status Status, label string) StatusIndicator {
124+ return StatusIndicator{
125+ Status: status,
126+ Label: label,
127+ Badge: false,
128+ }
129+}
130+
131+// NewStatusBadge creates a new status badge (with background).
132+func NewStatusBadge(status Status, label string) StatusIndicator {
133+ return StatusIndicator{
134+ Status: status,
135+ Label: label,
136+ Badge: true,
137+ }
138+}
139+
140+// View renders the status indicator.
141+func (s StatusIndicator) View() string {
142+ if s.Badge {
143+ return RenderStatusBadge(s.Status, s.Label)
144+ }
145+ return RenderStatus(s.Status, s.Label)
146+}
147+
148+// ContainerStatusIndicator renders container status with appropriate styling.
149+func ContainerStatusIndicator(status string) string {
150+ parsed := ParseStatus(status)
151+ return RenderStatus(parsed, status)
152+}
153+
154+// ContainerStatusBadge renders container status as a badge.
155+func ContainerStatusBadge(status string) string {
156+ parsed := ParseStatus(status)
157+ return RenderStatusBadge(parsed, status)
158+}
A internal/adapters/in/cli/ui/components/table.go +210 -0
@@ -0,0 +1,210 @@
1+package components
2+
3+import (
4+ "gordon/internal/adapters/in/cli/ui/styles"
5+
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/charmbracelet/lipgloss/table"
8+)
9+
10+// TableColumn defines a table column.
11+type TableColumn struct {
12+ Title string
13+ Width int
14+}
15+
16+// TableModel is a styled table component.
17+type TableModel struct {
18+ columns []TableColumn
19+ rows [][]string
20+ border lipgloss.Border
21+ borderStyle lipgloss.Style
22+ headerStyle lipgloss.Style
23+ cellStyle lipgloss.Style
24+ oddStyle lipgloss.Style
25+ evenStyle lipgloss.Style
26+ width int
27+}
28+
29+// TableOption configures a TableModel.
30+type TableOption func(*TableModel)
31+
32+// NewTable creates a new styled table.
33+func NewTable(opts ...TableOption) *TableModel {
34+ t := &TableModel{
35+ border: lipgloss.RoundedBorder(),
36+ borderStyle: lipgloss.NewStyle().Foreground(styles.ColorBorder),
37+ headerStyle: lipgloss.NewStyle().
38+ Bold(true).
39+ Foreground(styles.ColorPrimary).
40+ Padding(0, 1),
41+ cellStyle: lipgloss.NewStyle().
42+ Foreground(styles.ColorText).
43+ Padding(0, 1),
44+ oddStyle: lipgloss.NewStyle().
45+ Foreground(styles.ColorText).
46+ Padding(0, 1),
47+ evenStyle: lipgloss.NewStyle().
48+ Foreground(styles.ColorTextMuted).
49+ Padding(0, 1),
50+ }
51+
52+ for _, opt := range opts {
53+ opt(t)
54+ }
55+
56+ return t
57+}
58+
59+// WithColumns sets the table columns.
60+func WithColumns(cols []TableColumn) TableOption {
61+ return func(t *TableModel) {
62+ t.columns = cols
63+ }
64+}
65+
66+// WithRows sets the table rows.
67+func WithRows(rows [][]string) TableOption {
68+ return func(t *TableModel) {
69+ t.rows = rows
70+ }
71+}
72+
73+// WithBorder sets the table border style.
74+func WithBorder(b lipgloss.Border) TableOption {
75+ return func(t *TableModel) {
76+ t.border = b
77+ }
78+}
79+
80+// WithTableWidth sets the table width.
81+func WithTableWidth(w int) TableOption {
82+ return func(t *TableModel) {
83+ t.width = w
84+ }
85+}
86+
87+// WithHeaderStyle sets the header style.
88+func WithHeaderStyle(s lipgloss.Style) TableOption {
89+ return func(t *TableModel) {
90+ t.headerStyle = s
91+ }
92+}
93+
94+// WithCellStyle sets the cell style.
95+func WithCellStyle(s lipgloss.Style) TableOption {
96+ return func(t *TableModel) {
97+ t.cellStyle = s
98+ t.oddStyle = s
99+ t.evenStyle = s
100+ }
101+}
102+
103+// WithAlternateRows enables alternating row colors.
104+func WithAlternateRows(odd, even lipgloss.Style) TableOption {
105+ return func(t *TableModel) {
106+ t.oddStyle = odd
107+ t.evenStyle = even
108+ }
109+}
110+
111+// SetColumns sets the table columns.
112+func (t *TableModel) SetColumns(cols []TableColumn) {
113+ t.columns = cols
114+}
115+
116+// SetRows sets the table rows.
117+func (t *TableModel) SetRows(rows [][]string) {
118+ t.rows = rows
119+}
120+
121+// AddRow adds a row to the table.
122+func (t *TableModel) AddRow(row []string) {
123+ t.rows = append(t.rows, row)
124+}
125+
126+// ClearRows removes all rows from the table.
127+func (t *TableModel) ClearRows() {
128+ t.rows = nil
129+}
130+
131+// Render renders the table as a string.
132+func (t *TableModel) Render() string {
133+ if len(t.columns) == 0 {
134+ return ""
135+ }
136+
137+ // Extract headers
138+ headers := make([]string, len(t.columns))
139+ for i, col := range t.columns {
140+ headers[i] = col.Title
141+ }
142+
143+ // Create table
144+ tbl := table.New().
145+ Border(t.border).
146+ BorderStyle(t.borderStyle).
147+ Headers(headers...).
148+ Rows(t.rows...).
149+ StyleFunc(func(row, col int) lipgloss.Style {
150+ if row == table.HeaderRow {
151+ return t.headerStyle
152+ }
153+ if row%2 == 0 {
154+ return t.evenStyle
155+ }
156+ return t.oddStyle
157+ })
158+
159+ // Apply width if set
160+ if t.width > 0 {
161+ tbl = tbl.Width(t.width)
162+ }
163+
164+ return tbl.String()
165+}
166+
167+// View returns the table view (alias for Render).
168+func (t *TableModel) View() string {
169+ return t.Render()
170+}
171+
172+// SimpleTable creates a simple table with headers and rows.
173+// This is a convenience function for quick table rendering.
174+func SimpleTable(headers []string, rows [][]string) string {
175+ cols := make([]TableColumn, len(headers))
176+ for i, h := range headers {
177+ cols[i] = TableColumn{Title: h}
178+ }
179+
180+ t := NewTable(
181+ WithColumns(cols),
182+ WithRows(rows),
183+ )
184+
185+ return t.Render()
186+}
187+
188+// RouteTable creates a table specifically for displaying routes.
189+func RouteTable(routes [][]string) string {
190+ return SimpleTable(
191+ []string{"Domain", "Image", "Status"},
192+ routes,
193+ )
194+}
195+
196+// TokenTable creates a table specifically for displaying tokens.
197+func TokenTable(tokens [][]string) string {
198+ return SimpleTable(
199+ []string{"ID", "Subject", "Scopes", "Expires", "Revoked"},
200+ tokens,
201+ )
202+}
203+
204+// SecretTable creates a table specifically for displaying secrets.
205+func SecretTable(secrets [][]string) string {
206+ return SimpleTable(
207+ []string{"Key", "Value"},
208+ secrets,
209+ )
210+}
@@ -0,0 +1,210 @@
1+package components
2+
3+import (
4+ "gordon/internal/adapters/in/cli/ui/styles"
5+
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/charmbracelet/lipgloss/table"
8+)
9+
10+// TableColumn defines a table column.
11+type TableColumn struct {
12+ Title string
13+ Width int
14+}
15+
16+// TableModel is a styled table component.
17+type TableModel struct {
18+ columns []TableColumn
19+ rows [][]string
20+ border lipgloss.Border
21+ borderStyle lipgloss.Style
22+ headerStyle lipgloss.Style
23+ cellStyle lipgloss.Style
24+ oddStyle lipgloss.Style
25+ evenStyle lipgloss.Style
26+ width int
27+}
28+
29+// TableOption configures a TableModel.
30+type TableOption func(*TableModel)
31+
32+// NewTable creates a new styled table.
33+func NewTable(opts ...TableOption) *TableModel {
34+ t := &TableModel{
35+ border: lipgloss.RoundedBorder(),
36+ borderStyle: lipgloss.NewStyle().Foreground(styles.ColorBorder),
37+ headerStyle: lipgloss.NewStyle().
38+ Bold(true).
39+ Foreground(styles.ColorPrimary).
40+ Padding(0, 1),
41+ cellStyle: lipgloss.NewStyle().
42+ Foreground(styles.ColorText).
43+ Padding(0, 1),
44+ oddStyle: lipgloss.NewStyle().
45+ Foreground(styles.ColorText).
46+ Padding(0, 1),
47+ evenStyle: lipgloss.NewStyle().
48+ Foreground(styles.ColorTextMuted).
49+ Padding(0, 1),
50+ }
51+
52+ for _, opt := range opts {
53+ opt(t)
54+ }
55+
56+ return t
57+}
58+
59+// WithColumns sets the table columns.
60+func WithColumns(cols []TableColumn) TableOption {
61+ return func(t *TableModel) {
62+ t.columns = cols
63+ }
64+}
65+
66+// WithRows sets the table rows.
67+func WithRows(rows [][]string) TableOption {
68+ return func(t *TableModel) {
69+ t.rows = rows
70+ }
71+}
72+
73+// WithBorder sets the table border style.
74+func WithBorder(b lipgloss.Border) TableOption {
75+ return func(t *TableModel) {
76+ t.border = b
77+ }
78+}
79+
80+// WithTableWidth sets the table width.
81+func WithTableWidth(w int) TableOption {
82+ return func(t *TableModel) {
83+ t.width = w
84+ }
85+}
86+
87+// WithHeaderStyle sets the header style.
88+func WithHeaderStyle(s lipgloss.Style) TableOption {
89+ return func(t *TableModel) {
90+ t.headerStyle = s
91+ }
92+}
93+
94+// WithCellStyle sets the cell style.
95+func WithCellStyle(s lipgloss.Style) TableOption {
96+ return func(t *TableModel) {
97+ t.cellStyle = s
98+ t.oddStyle = s
99+ t.evenStyle = s
100+ }
101+}
102+
103+// WithAlternateRows enables alternating row colors.
104+func WithAlternateRows(odd, even lipgloss.Style) TableOption {
105+ return func(t *TableModel) {
106+ t.oddStyle = odd
107+ t.evenStyle = even
108+ }
109+}
110+
111+// SetColumns sets the table columns.
112+func (t *TableModel) SetColumns(cols []TableColumn) {
113+ t.columns = cols
114+}
115+
116+// SetRows sets the table rows.
117+func (t *TableModel) SetRows(rows [][]string) {
118+ t.rows = rows
119+}
120+
121+// AddRow adds a row to the table.
122+func (t *TableModel) AddRow(row []string) {
123+ t.rows = append(t.rows, row)
124+}
125+
126+// ClearRows removes all rows from the table.
127+func (t *TableModel) ClearRows() {
128+ t.rows = nil
129+}
130+
131+// Render renders the table as a string.
132+func (t *TableModel) Render() string {
133+ if len(t.columns) == 0 {
134+ return ""
135+ }
136+
137+ // Extract headers
138+ headers := make([]string, len(t.columns))
139+ for i, col := range t.columns {
140+ headers[i] = col.Title
141+ }
142+
143+ // Create table
144+ tbl := table.New().
145+ Border(t.border).
146+ BorderStyle(t.borderStyle).
147+ Headers(headers...).
148+ Rows(t.rows...).
149+ StyleFunc(func(row, col int) lipgloss.Style {
150+ if row == table.HeaderRow {
151+ return t.headerStyle
152+ }
153+ if row%2 == 0 {
154+ return t.evenStyle
155+ }
156+ return t.oddStyle
157+ })
158+
159+ // Apply width if set
160+ if t.width > 0 {
161+ tbl = tbl.Width(t.width)
162+ }
163+
164+ return tbl.String()
165+}
166+
167+// View returns the table view (alias for Render).
168+func (t *TableModel) View() string {
169+ return t.Render()
170+}
171+
172+// SimpleTable creates a simple table with headers and rows.
173+// This is a convenience function for quick table rendering.
174+func SimpleTable(headers []string, rows [][]string) string {
175+ cols := make([]TableColumn, len(headers))
176+ for i, h := range headers {
177+ cols[i] = TableColumn{Title: h}
178+ }
179+
180+ t := NewTable(
181+ WithColumns(cols),
182+ WithRows(rows),
183+ )
184+
185+ return t.Render()
186+}
187+
188+// RouteTable creates a table specifically for displaying routes.
189+func RouteTable(routes [][]string) string {
190+ return SimpleTable(
191+ []string{"Domain", "Image", "Status"},
192+ routes,
193+ )
194+}
195+
196+// TokenTable creates a table specifically for displaying tokens.
197+func TokenTable(tokens [][]string) string {
198+ return SimpleTable(
199+ []string{"ID", "Subject", "Scopes", "Expires", "Revoked"},
200+ tokens,
201+ )
202+}
203+
204+// SecretTable creates a table specifically for displaying secrets.
205+func SecretTable(secrets [][]string) string {
206+ return SimpleTable(
207+ []string{"Key", "Value"},
208+ secrets,
209+ )
210+}
A internal/adapters/in/cli/ui/styles/colors.go +88 -0
@@ -0,0 +1,88 @@
1+// Package styles provides a unified styling system for Gordon's TUI.
2+// The color palette is inspired by the Gordon webapp's terminal-themed design.
3+package styles
4+
5+import "github.com/charmbracelet/lipgloss"
6+
7+// Color definitions using the Gordon design system.
8+// These colors are adapted from the webapp CSS for terminal use.
9+var (
10+ // Primary colors - Terminal Green
11+ Green50 = lipgloss.Color("#e8fdf4")
12+ Green100 = lipgloss.Color("#c5fae4")
13+ Green200 = lipgloss.Color("#8ef5c8")
14+ Green300 = lipgloss.Color("#4dead7")
15+ Green400 = lipgloss.Color("#1adf9a")
16+ Green500 = lipgloss.Color("#00cc6a")
17+ Green600 = lipgloss.Color("#00995a")
18+ Green700 = lipgloss.Color("#007a4a")
19+ Green800 = lipgloss.Color("#005c38")
20+ Green900 = lipgloss.Color("#003d25")
21+
22+ // Secondary colors - Cyan
23+ Cyan50 = lipgloss.Color("#e6f9ff")
24+ Cyan100 = lipgloss.Color("#c2f0ff")
25+ Cyan200 = lipgloss.Color("#85e0ff")
26+ Cyan300 = lipgloss.Color("#47d1ff")
27+ Cyan400 = lipgloss.Color("#1ac5ff")
28+ Cyan500 = lipgloss.Color("#00a0cc")
29+ Cyan600 = lipgloss.Color("#0088b3")
30+ Cyan700 = lipgloss.Color("#006d8f")
31+ Cyan800 = lipgloss.Color("#00526c")
32+ Cyan900 = lipgloss.Color("#003748")
33+
34+ // Accent colors - Violet
35+ Violet50 = lipgloss.Color("#f5f3ff")
36+ Violet100 = lipgloss.Color("#ede9fe")
37+ Violet200 = lipgloss.Color("#ddd6fe")
38+ Violet300 = lipgloss.Color("#c4b5fd")
39+ Violet400 = lipgloss.Color("#a78bfa")
40+ Violet500 = lipgloss.Color("#8b5cf6")
41+ Violet600 = lipgloss.Color("#7c3aed")
42+ Violet700 = lipgloss.Color("#6d28d9")
43+ Violet800 = lipgloss.Color("#5b21b6")
44+ Violet900 = lipgloss.Color("#4c1d95")
45+
46+ // Neutral colors - for text and backgrounds
47+ Neutral50 = lipgloss.Color("#fafafa")
48+ Neutral100 = lipgloss.Color("#f5f5f5")
49+ Neutral200 = lipgloss.Color("#e5e5e5")
50+ Neutral300 = lipgloss.Color("#d4d4d4")
51+ Neutral400 = lipgloss.Color("#a3a3a3")
52+ Neutral500 = lipgloss.Color("#737373")
53+ Neutral600 = lipgloss.Color("#525252")
54+ Neutral700 = lipgloss.Color("#404040")
55+ Neutral800 = lipgloss.Color("#262626")
56+ Neutral900 = lipgloss.Color("#171717")
57+ Neutral950 = lipgloss.Color("#0a0a0a")
58+
59+ // Terminal neon colors (for dark terminal backgrounds)
60+ NeonGreen = lipgloss.Color("#00ff88")
61+ NeonCyan = lipgloss.Color("#00ccff")
62+ NeonViolet = lipgloss.Color("#a78bfa")
63+ NeonRed = lipgloss.Color("#ff4444")
64+ NeonYellow = lipgloss.Color("#fbbf24")
65+
66+ // Semantic colors
67+ ColorPrimary = NeonGreen
68+ ColorSecondary = NeonCyan
69+ ColorAccent = NeonViolet
70+ ColorSuccess = NeonGreen
71+ ColorWarning = NeonYellow
72+ ColorError = NeonRed
73+ ColorInfo = NeonCyan
74+
75+ // Text colors
76+ ColorText = Neutral200
77+ ColorTextMuted = Neutral500
78+ ColorTextBright = lipgloss.Color("#ffffff")
79+
80+ // Background colors
81+ ColorBg = lipgloss.Color("#000000")
82+ ColorBgSurface = Neutral900
83+ ColorBgMuted = Neutral800
84+
85+ // Border colors
86+ ColorBorder = Neutral700
87+ ColorBorderMuted = Neutral800
88+)
@@ -0,0 +1,88 @@
1+// Package styles provides a unified styling system for Gordon's TUI.
2+// The color palette is inspired by the Gordon webapp's terminal-themed design.
3+package styles
4+
5+import "github.com/charmbracelet/lipgloss"
6+
7+// Color definitions using the Gordon design system.
8+// These colors are adapted from the webapp CSS for terminal use.
9+var (
10+ // Primary colors - Terminal Green
11+ Green50 = lipgloss.Color("#e8fdf4")
12+ Green100 = lipgloss.Color("#c5fae4")
13+ Green200 = lipgloss.Color("#8ef5c8")
14+ Green300 = lipgloss.Color("#4dead7")
15+ Green400 = lipgloss.Color("#1adf9a")
16+ Green500 = lipgloss.Color("#00cc6a")
17+ Green600 = lipgloss.Color("#00995a")
18+ Green700 = lipgloss.Color("#007a4a")
19+ Green800 = lipgloss.Color("#005c38")
20+ Green900 = lipgloss.Color("#003d25")
21+
22+ // Secondary colors - Cyan
23+ Cyan50 = lipgloss.Color("#e6f9ff")
24+ Cyan100 = lipgloss.Color("#c2f0ff")
25+ Cyan200 = lipgloss.Color("#85e0ff")
26+ Cyan300 = lipgloss.Color("#47d1ff")
27+ Cyan400 = lipgloss.Color("#1ac5ff")
28+ Cyan500 = lipgloss.Color("#00a0cc")
29+ Cyan600 = lipgloss.Color("#0088b3")
30+ Cyan700 = lipgloss.Color("#006d8f")
31+ Cyan800 = lipgloss.Color("#00526c")
32+ Cyan900 = lipgloss.Color("#003748")
33+
34+ // Accent colors - Violet
35+ Violet50 = lipgloss.Color("#f5f3ff")
36+ Violet100 = lipgloss.Color("#ede9fe")
37+ Violet200 = lipgloss.Color("#ddd6fe")
38+ Violet300 = lipgloss.Color("#c4b5fd")
39+ Violet400 = lipgloss.Color("#a78bfa")
40+ Violet500 = lipgloss.Color("#8b5cf6")
41+ Violet600 = lipgloss.Color("#7c3aed")
42+ Violet700 = lipgloss.Color("#6d28d9")
43+ Violet800 = lipgloss.Color("#5b21b6")
44+ Violet900 = lipgloss.Color("#4c1d95")
45+
46+ // Neutral colors - for text and backgrounds
47+ Neutral50 = lipgloss.Color("#fafafa")
48+ Neutral100 = lipgloss.Color("#f5f5f5")
49+ Neutral200 = lipgloss.Color("#e5e5e5")
50+ Neutral300 = lipgloss.Color("#d4d4d4")
51+ Neutral400 = lipgloss.Color("#a3a3a3")
52+ Neutral500 = lipgloss.Color("#737373")
53+ Neutral600 = lipgloss.Color("#525252")
54+ Neutral700 = lipgloss.Color("#404040")
55+ Neutral800 = lipgloss.Color("#262626")
56+ Neutral900 = lipgloss.Color("#171717")
57+ Neutral950 = lipgloss.Color("#0a0a0a")
58+
59+ // Terminal neon colors (for dark terminal backgrounds)
60+ NeonGreen = lipgloss.Color("#00ff88")
61+ NeonCyan = lipgloss.Color("#00ccff")
62+ NeonViolet = lipgloss.Color("#a78bfa")
63+ NeonRed = lipgloss.Color("#ff4444")
64+ NeonYellow = lipgloss.Color("#fbbf24")
65+
66+ // Semantic colors
67+ ColorPrimary = NeonGreen
68+ ColorSecondary = NeonCyan
69+ ColorAccent = NeonViolet
70+ ColorSuccess = NeonGreen
71+ ColorWarning = NeonYellow
72+ ColorError = NeonRed
73+ ColorInfo = NeonCyan
74+
75+ // Text colors
76+ ColorText = Neutral200
77+ ColorTextMuted = Neutral500
78+ ColorTextBright = lipgloss.Color("#ffffff")
79+
80+ // Background colors
81+ ColorBg = lipgloss.Color("#000000")
82+ ColorBgSurface = Neutral900
83+ ColorBgMuted = Neutral800
84+
85+ // Border colors
86+ ColorBorder = Neutral700
87+ ColorBorderMuted = Neutral800
88+)
A internal/adapters/in/cli/ui/styles/icons.go +79 -0
@@ -0,0 +1,79 @@
1+package styles
2+
3+// Nerd Font icons for terminal UI.
4+// These icons require a Nerd Font compatible terminal font.
5+// Fallback ASCII alternatives are provided where practical.
6+const (
7+ // Status indicators
8+ IconSuccess = "" // nf-fa-check (U+F00C)
9+ IconError = "" // nf-fa-times (U+F00D)
10+ IconWarning = "" // nf-fa-exclamation_triangle (U+F071)
11+ IconInfo = "" // nf-fa-info_circle (U+F05A)
12+ IconPending = "" // nf-fa-clock_o (U+F017)
13+
14+ // Objects
15+ IconRoute = "" // nf-fa-sitemap (U+F0E8)
16+ IconSecret = "" // nf-fa-lock (U+F023)
17+ IconToken = "" // nf-fa-key (U+F084)
18+ IconContainer = "" // nf-oct-container (U+F489)
19+ IconServer = "" // nf-fa-server (U+F233)
20+ IconNetwork = "󰛳" // nf-md-lan (U+F06F3)
21+ IconVolume = "" // nf-fa-database (U+F1C0)
22+ IconImage = "" // nf-fa-archive (U+F187)
23+
24+ // Actions
25+ IconAdd = "" // nf-fa-plus (U+F067)
26+ IconRemove = "" // nf-fa-minus (U+F068)
27+ IconEdit = "" // nf-fa-pencil (U+F040)
28+ IconRefresh = "" // nf-fa-refresh (U+F021)
29+ IconUpload = "" // nf-fa-cloud_upload (U+F0EE)
30+ IconDelete = "" // nf-fa-trash (U+F1F8)
31+
32+ // Navigation
33+ IconArrowRight = "" // nf-fa-arrow_right (U+F061)
34+ IconArrowLeft = "" // nf-fa-arrow_left (U+F060)
35+ IconArrowUp = "" // nf-fa-arrow_up (U+F062)
36+ IconArrowDown = "" // nf-fa-arrow_down (U+F063)
37+ IconChevron = "" // nf-fa-chevron_right (U+F054)
38+
39+ // UI elements
40+ IconBullet = "▸" // Simple triangle bullet
41+ IconDot = "●" // Filled circle
42+ IconDotEmpty = "○" // Empty circle
43+ IconCheckbox = "" // nf-fa-square_o (U+F096)
44+ IconChecked = "" // nf-fa-check_square_o (U+F046)
45+ IconRadio = "" // nf-fa-circle_o (U+F10C)
46+ IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192)
47+
48+ // Spinners (individual frames)
49+ SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷"
50+ SpinnerLine = "|/-\\"
51+ SpinnerCircle = "◐◓◑◒"
52+ SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
53+
54+ // Borders and decorations
55+ BorderHorizontal = "─"
56+ BorderVertical = "│"
57+ BorderCornerTL = "┌"
58+ BorderCornerTR = "┐"
59+ BorderCornerBL = "└"
60+ BorderCornerBR = "┘"
61+ BorderT = "┬"
62+ BorderB = "┴"
63+ BorderL = "├"
64+ BorderR = "┤"
65+ BorderCross = "┼"
66+
67+ // Gordon branding
68+ IconGordon = "" // nf-fa-cube (U+F1B2)
69+)
70+
71+// ASCII fallback alternatives for terminals without Nerd Fonts.
72+const (
73+ AsciiSuccess = "[OK]"
74+ AsciiError = "[X]"
75+ AsciiWarning = "[!]"
76+ AsciiInfo = "[i]"
77+ AsciiBullet = ">"
78+ AsciiDot = "*"
79+)
@@ -0,0 +1,79 @@
1+package styles
2+
3+// Nerd Font icons for terminal UI.
4+// These icons require a Nerd Font compatible terminal font.
5+// Fallback ASCII alternatives are provided where practical.
6+const (
7+ // Status indicators
8+ IconSuccess = "" // nf-fa-check (U+F00C)
9+ IconError = "" // nf-fa-times (U+F00D)
10+ IconWarning = "" // nf-fa-exclamation_triangle (U+F071)
11+ IconInfo = "" // nf-fa-info_circle (U+F05A)
12+ IconPending = "" // nf-fa-clock_o (U+F017)
13+
14+ // Objects
15+ IconRoute = "" // nf-fa-sitemap (U+F0E8)
16+ IconSecret = "" // nf-fa-lock (U+F023)
17+ IconToken = "" // nf-fa-key (U+F084)
18+ IconContainer = "" // nf-oct-container (U+F489)
19+ IconServer = "" // nf-fa-server (U+F233)
20+ IconNetwork = "󰛳" // nf-md-lan (U+F06F3)
21+ IconVolume = "" // nf-fa-database (U+F1C0)
22+ IconImage = "" // nf-fa-archive (U+F187)
23+
24+ // Actions
25+ IconAdd = "" // nf-fa-plus (U+F067)
26+ IconRemove = "" // nf-fa-minus (U+F068)
27+ IconEdit = "" // nf-fa-pencil (U+F040)
28+ IconRefresh = "" // nf-fa-refresh (U+F021)
29+ IconUpload = "" // nf-fa-cloud_upload (U+F0EE)
30+ IconDelete = "" // nf-fa-trash (U+F1F8)
31+
32+ // Navigation
33+ IconArrowRight = "" // nf-fa-arrow_right (U+F061)
34+ IconArrowLeft = "" // nf-fa-arrow_left (U+F060)
35+ IconArrowUp = "" // nf-fa-arrow_up (U+F062)
36+ IconArrowDown = "" // nf-fa-arrow_down (U+F063)
37+ IconChevron = "" // nf-fa-chevron_right (U+F054)
38+
39+ // UI elements
40+ IconBullet = "▸" // Simple triangle bullet
41+ IconDot = "●" // Filled circle
42+ IconDotEmpty = "○" // Empty circle
43+ IconCheckbox = "" // nf-fa-square_o (U+F096)
44+ IconChecked = "" // nf-fa-check_square_o (U+F046)
45+ IconRadio = "" // nf-fa-circle_o (U+F10C)
46+ IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192)
47+
48+ // Spinners (individual frames)
49+ SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷"
50+ SpinnerLine = "|/-\\"
51+ SpinnerCircle = "◐◓◑◒"
52+ SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
53+
54+ // Borders and decorations
55+ BorderHorizontal = "─"
56+ BorderVertical = "│"
57+ BorderCornerTL = "┌"
58+ BorderCornerTR = "┐"
59+ BorderCornerBL = "└"
60+ BorderCornerBR = "┘"
61+ BorderT = "┬"
62+ BorderB = "┴"
63+ BorderL = "├"
64+ BorderR = "┤"
65+ BorderCross = "┼"
66+
67+ // Gordon branding
68+ IconGordon = "" // nf-fa-cube (U+F1B2)
69+)
70+
71+// ASCII fallback alternatives for terminals without Nerd Fonts.
72+const (
73+ AsciiSuccess = "[OK]"
74+ AsciiError = "[X]"
75+ AsciiWarning = "[!]"
76+ AsciiInfo = "[i]"
77+ AsciiBullet = ">"
78+ AsciiDot = "*"
79+)
A internal/adapters/in/cli/ui/styles/theme.go +279 -0
@@ -0,0 +1,279 @@
1+package styles
2+
3+import "github.com/charmbracelet/lipgloss"
4+
5+// Theme contains all composed styles for the Gordon TUI.
6+// Styles are organized by component type for easy discovery.
7+var Theme = struct {
8+ // Text styles
9+ Title lipgloss.Style
10+ Subtitle lipgloss.Style
11+ Heading lipgloss.Style
12+ Body lipgloss.Style
13+ Muted lipgloss.Style
14+ Bold lipgloss.Style
15+ Highlight lipgloss.Style
16+
17+ // Status styles
18+ Success lipgloss.Style
19+ Error lipgloss.Style
20+ Warning lipgloss.Style
21+ Info lipgloss.Style
22+
23+ // Badge styles (compact status indicators)
24+ BadgeSuccess lipgloss.Style
25+ BadgeError lipgloss.Style
26+ BadgeWarning lipgloss.Style
27+ BadgeInfo lipgloss.Style
28+ BadgePending lipgloss.Style
29+
30+ // Table styles
31+ TableHeader lipgloss.Style
32+ TableRow lipgloss.Style
33+ TableRowSelected lipgloss.Style
34+ TableRowAlt lipgloss.Style
35+ TableCell lipgloss.Style
36+ TableBorder lipgloss.Style
37+
38+ // Form styles
39+ FormLabel lipgloss.Style
40+ FormInput lipgloss.Style
41+ FormPlaceholder lipgloss.Style
42+ FormFocused lipgloss.Style
43+ FormError lipgloss.Style
44+
45+ // List styles
46+ ListItem lipgloss.Style
47+ ListItemSelected lipgloss.Style
48+ ListBullet lipgloss.Style
49+
50+ // Box/Container styles
51+ Box lipgloss.Style
52+ BoxHighlight lipgloss.Style
53+ BoxError lipgloss.Style
54+
55+ // Banner/Header styles
56+ Banner lipgloss.Style
57+ BannerTitle lipgloss.Style
58+
59+ // Help styles
60+ HelpKey lipgloss.Style
61+ HelpDesc lipgloss.Style
62+}{
63+ // Text styles
64+ Title: lipgloss.NewStyle().
65+ Bold(true).
66+ Foreground(ColorPrimary),
67+
68+ Subtitle: lipgloss.NewStyle().
69+ Foreground(ColorTextMuted),
70+
71+ Heading: lipgloss.NewStyle().
72+ Bold(true).
73+ Foreground(ColorText),
74+
75+ Body: lipgloss.NewStyle().
76+ Foreground(ColorText),
77+
78+ Muted: lipgloss.NewStyle().
79+ Foreground(ColorTextMuted),
80+
81+ Bold: lipgloss.NewStyle().
82+ Bold(true).
83+ Foreground(ColorText),
84+
85+ Highlight: lipgloss.NewStyle().
86+ Foreground(ColorPrimary),
87+
88+ // Status styles
89+ Success: lipgloss.NewStyle().
90+ Foreground(ColorSuccess),
91+
92+ Error: lipgloss.NewStyle().
93+ Foreground(ColorError),
94+
95+ Warning: lipgloss.NewStyle().
96+ Foreground(ColorWarning),
97+
98+ Info: lipgloss.NewStyle().
99+ Foreground(ColorInfo),
100+
101+ // Badge styles
102+ BadgeSuccess: lipgloss.NewStyle().
103+ Foreground(ColorBg).
104+ Background(ColorSuccess).
105+ Padding(0, 1),
106+
107+ BadgeError: lipgloss.NewStyle().
108+ Foreground(ColorBg).
109+ Background(ColorError).
110+ Padding(0, 1),
111+
112+ BadgeWarning: lipgloss.NewStyle().
113+ Foreground(ColorBg).
114+ Background(ColorWarning).
115+ Padding(0, 1),
116+
117+ BadgeInfo: lipgloss.NewStyle().
118+ Foreground(ColorBg).
119+ Background(ColorInfo).
120+ Padding(0, 1),
121+
122+ BadgePending: lipgloss.NewStyle().
123+ Foreground(ColorBg).
124+ Background(ColorTextMuted).
125+ Padding(0, 1),
126+
127+ // Table styles
128+ TableHeader: lipgloss.NewStyle().
129+ Bold(true).
130+ Foreground(ColorPrimary).
131+ BorderBottom(true).
132+ BorderStyle(lipgloss.NormalBorder()).
133+ BorderForeground(ColorBorder),
134+
135+ TableRow: lipgloss.NewStyle().
136+ Foreground(ColorText),
137+
138+ TableRowSelected: lipgloss.NewStyle().
139+ Foreground(ColorPrimary).
140+ Background(ColorBgMuted),
141+
142+ TableRowAlt: lipgloss.NewStyle().
143+ Foreground(ColorTextMuted),
144+
145+ TableCell: lipgloss.NewStyle().
146+ Padding(0, 1),
147+
148+ TableBorder: lipgloss.NewStyle().
149+ Foreground(ColorBorder),
150+
151+ // Form styles
152+ FormLabel: lipgloss.NewStyle().
153+ Foreground(ColorText).
154+ Bold(true),
155+
156+ FormInput: lipgloss.NewStyle().
157+ Foreground(ColorText).
158+ Background(ColorBgMuted).
159+ Padding(0, 1),
160+
161+ FormPlaceholder: lipgloss.NewStyle().
162+ Foreground(ColorTextMuted),
163+
164+ FormFocused: lipgloss.NewStyle().
165+ Foreground(ColorPrimary).
166+ Background(ColorBgMuted).
167+ BorderLeft(true).
168+ BorderStyle(lipgloss.NormalBorder()).
169+ BorderForeground(ColorPrimary).
170+ Padding(0, 1),
171+
172+ FormError: lipgloss.NewStyle().
173+ Foreground(ColorError),
174+
175+ // List styles
176+ ListItem: lipgloss.NewStyle().
177+ Foreground(ColorText).
178+ PaddingLeft(2),
179+
180+ ListItemSelected: lipgloss.NewStyle().
181+ Foreground(ColorPrimary).
182+ PaddingLeft(2),
183+
184+ ListBullet: lipgloss.NewStyle().
185+ Foreground(ColorPrimary),
186+
187+ // Box/Container styles
188+ Box: lipgloss.NewStyle().
189+ Border(lipgloss.RoundedBorder()).
190+ BorderForeground(ColorBorder).
191+ Padding(1, 2),
192+
193+ BoxHighlight: lipgloss.NewStyle().
194+ Border(lipgloss.RoundedBorder()).
195+ BorderForeground(ColorPrimary).
196+ Padding(1, 2),
197+
198+ BoxError: lipgloss.NewStyle().
199+ Border(lipgloss.RoundedBorder()).
200+ BorderForeground(ColorError).
201+ Padding(1, 2),
202+
203+ // Banner/Header styles
204+ Banner: lipgloss.NewStyle().
205+ Border(lipgloss.RoundedBorder()).
206+ BorderForeground(ColorPrimary).
207+ Padding(0, 2),
208+
209+ BannerTitle: lipgloss.NewStyle().
210+ Bold(true).
211+ Foreground(ColorPrimary),
212+
213+ // Help styles
214+ HelpKey: lipgloss.NewStyle().
215+ Foreground(ColorPrimary),
216+
217+ HelpDesc: lipgloss.NewStyle().
218+ Foreground(ColorTextMuted),
219+}
220+
221+// Render helpers for common patterns.
222+
223+// RenderStatus returns a styled status indicator with icon.
224+func RenderStatus(status string, running bool) string {
225+ if running {
226+ return Theme.Success.Render(IconSuccess + " " + status)
227+ }
228+ return Theme.Error.Render(IconError + " " + status)
229+}
230+
231+// RenderBadge returns a styled badge for the given status.
232+func RenderBadge(status string) string {
233+ switch status {
234+ case "running", "success", "ok", "active":
235+ return Theme.BadgeSuccess.Render(status)
236+ case "error", "failed", "stopped":
237+ return Theme.BadgeError.Render(status)
238+ case "warning", "degraded":
239+ return Theme.BadgeWarning.Render(status)
240+ case "pending", "starting":
241+ return Theme.BadgePending.Render(status)
242+ default:
243+ return Theme.BadgeInfo.Render(status)
244+ }
245+}
246+
247+// RenderKeyHelp returns formatted key binding help text.
248+func RenderKeyHelp(key, desc string) string {
249+ return Theme.HelpKey.Render(key) + " " + Theme.HelpDesc.Render(desc)
250+}
251+
252+// RenderListItem returns a formatted list item with bullet.
253+func RenderListItem(item string, selected bool) string {
254+ bullet := Theme.ListBullet.Render(IconBullet)
255+ if selected {
256+ return bullet + " " + Theme.ListItemSelected.Render(item)
257+ }
258+ return bullet + " " + Theme.ListItem.Render(item)
259+}
260+
261+// RenderError returns a styled error message.
262+func RenderError(msg string) string {
263+ return Theme.Error.Render(IconError + " " + msg)
264+}
265+
266+// RenderSuccess returns a styled success message.
267+func RenderSuccess(msg string) string {
268+ return Theme.Success.Render(IconSuccess + " " + msg)
269+}
270+
271+// RenderWarning returns a styled warning message.
272+func RenderWarning(msg string) string {
273+ return Theme.Warning.Render(IconWarning + " " + msg)
274+}
275+
276+// RenderInfo returns a styled info message.
277+func RenderInfo(msg string) string {
278+ return Theme.Info.Render(IconInfo + " " + msg)
279+}
@@ -0,0 +1,279 @@
1+package styles
2+
3+import "github.com/charmbracelet/lipgloss"
4+
5+// Theme contains all composed styles for the Gordon TUI.
6+// Styles are organized by component type for easy discovery.
7+var Theme = struct {
8+ // Text styles
9+ Title lipgloss.Style
10+ Subtitle lipgloss.Style
11+ Heading lipgloss.Style
12+ Body lipgloss.Style
13+ Muted lipgloss.Style
14+ Bold lipgloss.Style
15+ Highlight lipgloss.Style
16+
17+ // Status styles
18+ Success lipgloss.Style
19+ Error lipgloss.Style
20+ Warning lipgloss.Style
21+ Info lipgloss.Style
22+
23+ // Badge styles (compact status indicators)
24+ BadgeSuccess lipgloss.Style
25+ BadgeError lipgloss.Style
26+ BadgeWarning lipgloss.Style
27+ BadgeInfo lipgloss.Style
28+ BadgePending lipgloss.Style
29+
30+ // Table styles
31+ TableHeader lipgloss.Style
32+ TableRow lipgloss.Style
33+ TableRowSelected lipgloss.Style
34+ TableRowAlt lipgloss.Style
35+ TableCell lipgloss.Style
36+ TableBorder lipgloss.Style
37+
38+ // Form styles
39+ FormLabel lipgloss.Style
40+ FormInput lipgloss.Style
41+ FormPlaceholder lipgloss.Style
42+ FormFocused lipgloss.Style
43+ FormError lipgloss.Style
44+
45+ // List styles
46+ ListItem lipgloss.Style
47+ ListItemSelected lipgloss.Style
48+ ListBullet lipgloss.Style
49+
50+ // Box/Container styles
51+ Box lipgloss.Style
52+ BoxHighlight lipgloss.Style
53+ BoxError lipgloss.Style
54+
55+ // Banner/Header styles
56+ Banner lipgloss.Style
57+ BannerTitle lipgloss.Style
58+
59+ // Help styles
60+ HelpKey lipgloss.Style
61+ HelpDesc lipgloss.Style
62+}{
63+ // Text styles
64+ Title: lipgloss.NewStyle().
65+ Bold(true).
66+ Foreground(ColorPrimary),
67+
68+ Subtitle: lipgloss.NewStyle().
69+ Foreground(ColorTextMuted),
70+
71+ Heading: lipgloss.NewStyle().
72+ Bold(true).
73+ Foreground(ColorText),
74+
75+ Body: lipgloss.NewStyle().
76+ Foreground(ColorText),
77+
78+ Muted: lipgloss.NewStyle().
79+ Foreground(ColorTextMuted),
80+
81+ Bold: lipgloss.NewStyle().
82+ Bold(true).
83+ Foreground(ColorText),
84+
85+ Highlight: lipgloss.NewStyle().
86+ Foreground(ColorPrimary),
87+
88+ // Status styles
89+ Success: lipgloss.NewStyle().
90+ Foreground(ColorSuccess),
91+
92+ Error: lipgloss.NewStyle().
93+ Foreground(ColorError),
94+
95+ Warning: lipgloss.NewStyle().
96+ Foreground(ColorWarning),
97+
98+ Info: lipgloss.NewStyle().
99+ Foreground(ColorInfo),
100+
101+ // Badge styles
102+ BadgeSuccess: lipgloss.NewStyle().
103+ Foreground(ColorBg).
104+ Background(ColorSuccess).
105+ Padding(0, 1),
106+
107+ BadgeError: lipgloss.NewStyle().
108+ Foreground(ColorBg).
109+ Background(ColorError).
110+ Padding(0, 1),
111+
112+ BadgeWarning: lipgloss.NewStyle().
113+ Foreground(ColorBg).
114+ Background(ColorWarning).
115+ Padding(0, 1),
116+
117+ BadgeInfo: lipgloss.NewStyle().
118+ Foreground(ColorBg).
119+ Background(ColorInfo).
120+ Padding(0, 1),
121+
122+ BadgePending: lipgloss.NewStyle().
123+ Foreground(ColorBg).
124+ Background(ColorTextMuted).
125+ Padding(0, 1),
126+
127+ // Table styles
128+ TableHeader: lipgloss.NewStyle().
129+ Bold(true).
130+ Foreground(ColorPrimary).
131+ BorderBottom(true).
132+ BorderStyle(lipgloss.NormalBorder()).
133+ BorderForeground(ColorBorder),
134+
135+ TableRow: lipgloss.NewStyle().
136+ Foreground(ColorText),
137+
138+ TableRowSelected: lipgloss.NewStyle().
139+ Foreground(ColorPrimary).
140+ Background(ColorBgMuted),
141+
142+ TableRowAlt: lipgloss.NewStyle().
143+ Foreground(ColorTextMuted),
144+
145+ TableCell: lipgloss.NewStyle().
146+ Padding(0, 1),
147+
148+ TableBorder: lipgloss.NewStyle().
149+ Foreground(ColorBorder),
150+
151+ // Form styles
152+ FormLabel: lipgloss.NewStyle().
153+ Foreground(ColorText).
154+ Bold(true),
155+
156+ FormInput: lipgloss.NewStyle().
157+ Foreground(ColorText).
158+ Background(ColorBgMuted).
159+ Padding(0, 1),
160+
161+ FormPlaceholder: lipgloss.NewStyle().
162+ Foreground(ColorTextMuted),
163+
164+ FormFocused: lipgloss.NewStyle().
165+ Foreground(ColorPrimary).
166+ Background(ColorBgMuted).
167+ BorderLeft(true).
168+ BorderStyle(lipgloss.NormalBorder()).
169+ BorderForeground(ColorPrimary).
170+ Padding(0, 1),
171+
172+ FormError: lipgloss.NewStyle().
173+ Foreground(ColorError),
174+
175+ // List styles
176+ ListItem: lipgloss.NewStyle().
177+ Foreground(ColorText).
178+ PaddingLeft(2),
179+
180+ ListItemSelected: lipgloss.NewStyle().
181+ Foreground(ColorPrimary).
182+ PaddingLeft(2),
183+
184+ ListBullet: lipgloss.NewStyle().
185+ Foreground(ColorPrimary),
186+
187+ // Box/Container styles
188+ Box: lipgloss.NewStyle().
189+ Border(lipgloss.RoundedBorder()).
190+ BorderForeground(ColorBorder).
191+ Padding(1, 2),
192+
193+ BoxHighlight: lipgloss.NewStyle().
194+ Border(lipgloss.RoundedBorder()).
195+ BorderForeground(ColorPrimary).
196+ Padding(1, 2),
197+
198+ BoxError: lipgloss.NewStyle().
199+ Border(lipgloss.RoundedBorder()).
200+ BorderForeground(ColorError).
201+ Padding(1, 2),
202+
203+ // Banner/Header styles
204+ Banner: lipgloss.NewStyle().
205+ Border(lipgloss.RoundedBorder()).
206+ BorderForeground(ColorPrimary).
207+ Padding(0, 2),
208+
209+ BannerTitle: lipgloss.NewStyle().
210+ Bold(true).
211+ Foreground(ColorPrimary),
212+
213+ // Help styles
214+ HelpKey: lipgloss.NewStyle().
215+ Foreground(ColorPrimary),
216+
217+ HelpDesc: lipgloss.NewStyle().
218+ Foreground(ColorTextMuted),
219+}
220+
221+// Render helpers for common patterns.
222+
223+// RenderStatus returns a styled status indicator with icon.
224+func RenderStatus(status string, running bool) string {
225+ if running {
226+ return Theme.Success.Render(IconSuccess + " " + status)
227+ }
228+ return Theme.Error.Render(IconError + " " + status)
229+}
230+
231+// RenderBadge returns a styled badge for the given status.
232+func RenderBadge(status string) string {
233+ switch status {
234+ case "running", "success", "ok", "active":
235+ return Theme.BadgeSuccess.Render(status)
236+ case "error", "failed", "stopped":
237+ return Theme.BadgeError.Render(status)
238+ case "warning", "degraded":
239+ return Theme.BadgeWarning.Render(status)
240+ case "pending", "starting":
241+ return Theme.BadgePending.Render(status)
242+ default:
243+ return Theme.BadgeInfo.Render(status)
244+ }
245+}
246+
247+// RenderKeyHelp returns formatted key binding help text.
248+func RenderKeyHelp(key, desc string) string {
249+ return Theme.HelpKey.Render(key) + " " + Theme.HelpDesc.Render(desc)
250+}
251+
252+// RenderListItem returns a formatted list item with bullet.
253+func RenderListItem(item string, selected bool) string {
254+ bullet := Theme.ListBullet.Render(IconBullet)
255+ if selected {
256+ return bullet + " " + Theme.ListItemSelected.Render(item)
257+ }
258+ return bullet + " " + Theme.ListItem.Render(item)
259+}
260+
261+// RenderError returns a styled error message.
262+func RenderError(msg string) string {
263+ return Theme.Error.Render(IconError + " " + msg)
264+}
265+
266+// RenderSuccess returns a styled success message.
267+func RenderSuccess(msg string) string {
268+ return Theme.Success.Render(IconSuccess + " " + msg)
269+}
270+
271+// RenderWarning returns a styled warning message.
272+func RenderWarning(msg string) string {
273+ return Theme.Warning.Render(IconWarning + " " + msg)
274+}
275+
276+// RenderInfo returns a styled info message.
277+func RenderInfo(msg string) string {
278+ return Theme.Info.Render(IconInfo + " " + msg)
279+}

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