feat(cli): add serve command with remote management
- commit
- 382b9376f6fd7129520832ef7ab5476fe78fea25
- author
- bnema · · 2026-01-16 20:49 UTC
- parent
- e4beced
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 \ | |||
| 63 | 63 | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | |
| 64 | 64 | ||
| 65 | 65 | # Default command | |
| 66 | - | CMD ["./gordon", "start"] | |
| 66 | + | CMD ["./gordon", "serve"] | |
| 67 | 67 | ||
| 68 | 68 | # Metadata | |
| 69 | 69 | 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 1 | 63 | CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1 | ||
| 64 | 64 | ||||
| 65 | # Default command | 65 | # Default command | ||
| 66 | - | CMD ["./gordon", "start"] | 66 | + | CMD ["./gordon", "serve"] |
| 67 | 67 | ||||
| 68 | # Metadata | 68 | # 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 @@ | |||
| 3 | 3 | package cli | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | - | "context" | |
| 7 | - | ||
| 6 | + | "gordon/internal/adapters/in/cli/remote" | |
| 8 | 7 | "gordon/internal/app" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/spf13/cobra" | |
| @@ -15,6 +14,10 @@ var ( | |||
| 15 | 14 | Version = "dev" | |
| 16 | 15 | Commit = "unknown" | |
| 17 | 16 | BuildDate = "unknown" | |
| 17 | + | ||
| 18 | + | // Global flags for remote targeting | |
| 19 | + | targetFlag string | |
| 20 | + | tokenFlag string | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| 20 | 23 | // NewRootCmd creates the root command for Gordon CLI. | |
| @@ -26,37 +29,50 @@ func NewRootCmd() *cobra.Command { | |||
| 26 | 29 | a Docker registry with automatic container deployment capabilities. | |
| 27 | 30 | ||
| 28 | 31 | 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 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 | |
| 34 | 45 | rootCmd.AddCommand(newReloadCmd()) | |
| 35 | 46 | rootCmd.AddCommand(newDeployCmd()) | |
| 36 | 47 | rootCmd.AddCommand(newLogsCmd()) | |
| 37 | 48 | rootCmd.AddCommand(newVersionCmd()) | |
| 38 | 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 | 57 | return rootCmd | |
| 41 | 58 | } | |
| 42 | 59 | ||
| 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 | |
| 54 | 66 | } | |
| 55 | 67 | ||
| 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 | + | } | |
| 58 | 71 | ||
| 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 | |
| 60 | 76 | } | |
| 61 | 77 | ||
| 62 | 78 | // newReloadCmd creates the reload command. | |
| @@ -3,8 +3,7 @@ | |||||
| 3 | package cli | 3 | 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 on | 31 | 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 subcommands | 38 | + | // 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 rootCmd | 57 | 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 string | 62 | + | 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 flags | 68 | + | 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 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 | |||
| 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 | + | } | |||