mirror of
https://github.com/coollabsio/coolify-cli.git
synced 2026-06-23 17:55:04 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a128de992 | |||
| 6495804344 | |||
| 3a994cb19e | |||
| 9b8992d177 | |||
| de6418e532 | |||
| 5e8c823637 | |||
| 7c370540e2 | |||
| 35f152b3d1 | |||
| 2b8a3bd120 | |||
| 77a61d614e | |||
| 255b918d02 | |||
| 200313c1b8 | |||
| dd0d46b0fc | |||
| 7c6a6b4292 | |||
| ef4a847f10 | |||
| b22f7b6943 | |||
| 9a4ef0d6ac | |||
| 98a624af27 | |||
| cb185da557 | |||
| d809990bec | |||
| f66c4f4217 | |||
| decc3e092a | |||
| 611b14d2ea | |||
| d22e6607a9 | |||
| 1126defb7c | |||
| b4148d6344 | |||
| 8c38a5447a |
@@ -1,46 +0,0 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "./coolify"
|
||||
cmd = "go build -o ./coolify ."
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", ".git", ".conductor"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = "echo 'Build complete. Binary: ./coolify'"
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
keep_scroll = true
|
||||
+38
-25
@@ -1,29 +1,42 @@
|
||||
You are an expert AI programming assistant specializing in building CLI applications with Go, using Cobra for command-line interface management and Bubble Tea for terminal user interfaces.
|
||||
|
||||
You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22.
|
||||
Always use Go 1.24 and be familiar with CLI development best practices, Go idioms, and terminal UI design principles.
|
||||
|
||||
Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms.
|
||||
When using lipgloss for terminal styling, use these Coolify brand colors via the pkg/tui package.
|
||||
|
||||
- Follow the user's requirements carefully & to the letter.
|
||||
- First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail.
|
||||
- Confirm the plan, then write code!
|
||||
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs.
|
||||
- Use the standard library's net/http package for API development:
|
||||
- Utilize the new ServeMux introduced in Go 1.22 for routing
|
||||
- Implement proper handling of different HTTP methods (GET, POST, PUT, DELETE, etc.)
|
||||
- Use method handlers with appropriate signatures (e.g., func(w http.ResponseWriter, r *http.Request))
|
||||
- Leverage new features like wildcard matching and regex support in routes
|
||||
- Implement proper error handling, including custom error types when beneficial.
|
||||
- Use appropriate status codes and format JSON responses correctly.
|
||||
- Implement input validation for API endpoints.
|
||||
- Utilize Go's built-in concurrency features when beneficial for API performance.
|
||||
- Follow RESTful API design principles and best practices.
|
||||
- Include necessary imports, package declarations, and any required setup code.
|
||||
- Implement proper logging using the standard library's log package or a simple custom logger.
|
||||
- Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication).
|
||||
- Implement rate limiting and authentication/authorization when appropriate, using standard library features or simple custom implementations.
|
||||
- Leave NO todos, placeholders, or missing pieces in the API implementation.
|
||||
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
|
||||
- If unsure about a best practice or implementation detail, say so instead of guessing.
|
||||
- Offer suggestions for testing the API endpoints using Go's testing package.
|
||||
When searching for schemas look at https://github.com/coollabsio/coolify/blob/main/openapi.yaml to find the most up to date schema for the struct we are looking to define. Make sure when creating a schema that you place the struct in cmd/coolTypes package.
|
||||
|
||||
Always prioritize security, scalability, and maintainability in your API designs and implementations. Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs.
|
||||
- First think step-by-step - describe your plan for the CLI structure, commands, and user interaction flow in pseudocode, written out in great detail.
|
||||
- Confirm the plan, then write code!
|
||||
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for CLI applications.
|
||||
- Use Cobra for command-line interface development:
|
||||
- Organize commands in a clear, hierarchical structure
|
||||
- Implement proper command flags and arguments
|
||||
- Use persistent flags when appropriate
|
||||
- Follow Cobra's best practices for command organization
|
||||
- Implement proper command aliases and short descriptions
|
||||
- Use Bubble Tea for terminal user interfaces:
|
||||
- Design intuitive and responsive terminal UIs
|
||||
- Implement proper state management
|
||||
- Handle user input appropriately
|
||||
- Use appropriate Bubble Tea components and styling
|
||||
- Follow terminal UI best practices
|
||||
- Implement proper error handling, including custom error types when beneficial
|
||||
- Use appropriate exit codes and error messages
|
||||
- Implement input validation for command arguments and flags
|
||||
- Utilize Go's built-in concurrency features when beneficial for CLI performance
|
||||
- Follow CLI design principles and best practices:
|
||||
- Keep commands simple and focused
|
||||
- Use clear, consistent naming conventions
|
||||
- Provide helpful usage information
|
||||
- Implement proper help text and documentation
|
||||
- Include necessary imports, package declarations, and any required setup code
|
||||
- Implement proper logging using appropriate CLI-friendly logging packages
|
||||
- Consider implementing middleware for cross-cutting concerns (e.g., logging, configuration)
|
||||
- Implement proper configuration management when appropriate
|
||||
- Leave NO todos, placeholders, or missing pieces in the CLI implementation
|
||||
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms
|
||||
- If unsure about a best practice or implementation detail, say so instead of guessing
|
||||
- Offer suggestions for testing the CLI commands using Go's testing package
|
||||
|
||||
Always prioritize user experience, maintainability, and cross-platform compatibility in your CLI designs and implementations. Leverage the power of Cobra and Bubble Tea to create efficient and user-friendly terminal applications.
|
||||
|
||||
+4
-6
@@ -1,11 +1,9 @@
|
||||
coolify-cli
|
||||
cli-coolify
|
||||
coolify
|
||||
cli
|
||||
config.json
|
||||
dist
|
||||
.vagrant
|
||||
.test
|
||||
|
||||
# Generated documentation (can be regenerated)
|
||||
man/
|
||||
docs/cli/
|
||||
|
||||
# Test coverage
|
||||
coverage.out
|
||||
@@ -0,0 +1,22 @@
|
||||
version: "2"
|
||||
linters:
|
||||
enable:
|
||||
- gocritic
|
||||
settings:
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
disabled-checks:
|
||||
- hugeParam
|
||||
- rangeValCopy
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
formatters:
|
||||
exclusions:
|
||||
paths:
|
||||
- "pkg/gen/*.go"
|
||||
+13
-1
@@ -10,5 +10,17 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w -X github.com/coollabsio/cli-coolify/cmd/runtime.Version={{.Version}}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- CGO_ENABLED=0
|
||||
archives:
|
||||
- formats: ['tar.gz']
|
||||
name_template: >-
|
||||
coolify_{{ .Version }}_
|
||||
{{- .Os }}_{{ .Arch }}
|
||||
checksum:
|
||||
name_template: 'coolify_{{ .Version }}_checksums.txt'
|
||||
release:
|
||||
prerelease: auto
|
||||
make_latest: "{{ not .Prerelease }}"
|
||||
-584
@@ -1,584 +0,0 @@
|
||||
# Coolify CLI Architecture
|
||||
|
||||
This document describes the architecture and design principles of the Coolify CLI.
|
||||
|
||||
## Overview
|
||||
|
||||
The Coolify CLI is a command-line interface for managing Coolify instances, servers, projects, and deployments. It follows a layered architecture pattern that separates concerns and promotes maintainability.
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ User Interface │
|
||||
│ (Terminal/Shell) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Command Layer (cmd/) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ servers │ │ deploy │ │ projects │ ... │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ • CLI parsing & validation │
|
||||
│ • Flag handling │
|
||||
│ • Output formatting │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Service Layer (internal/service/) │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ServerService│ │DeployService │ │ProjectService│ │
|
||||
│ └─────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ • Business logic │
|
||||
│ • Request validation │
|
||||
│ • Response transformation │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ API Client Layer (internal/api/) │
|
||||
│ ┌───────────────────────────────────────────────────┐ │
|
||||
│ │ HTTP Client (api.Client) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ • HTTP requests/responses │
|
||||
│ • Authentication (Bearer tokens) │
|
||||
│ • Retry logic │
|
||||
│ • Error handling │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Coolify API (External) │
|
||||
│ https://instance.coolify.io/api/v1/ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Supporting Components
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Configuration (internal/config/) │
|
||||
│ • Multi-instance management │
|
||||
│ • Default instance selection │
|
||||
│ • Token storage │
|
||||
│ • ~/.config/coolify/config.json │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Output Formatters (internal/output/) │
|
||||
│ ┌─────────┐ ┌────────┐ ┌─────────┐ │
|
||||
│ │ Table │ │ JSON │ │ Pretty │ │
|
||||
│ └─────────┘ └────────┘ └─────────┘ │
|
||||
│ • Flexible output formats │
|
||||
│ • Sensitive data masking │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Data Models (internal/models/) │
|
||||
│ • Server, Project, Resource, Deployment │
|
||||
│ • Request/Response structures │
|
||||
│ • JSON marshaling/unmarshaling │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### 1. Command Layer (`cmd/`)
|
||||
|
||||
**Purpose**: Handle CLI user interface and interaction
|
||||
|
||||
**Responsibilities**:
|
||||
- Parse command-line arguments and flags
|
||||
- Validate user input
|
||||
- Coordinate with service layer
|
||||
- Format and display output
|
||||
- Handle errors gracefully
|
||||
|
||||
**Key Files**:
|
||||
- `root.go` - Root command, global flags, initialization
|
||||
- `servers.go` - Server management commands
|
||||
- `deploy.go` - Deployment commands
|
||||
- `instances.go` - Instance configuration commands
|
||||
- `projects.go` - Project listing and inspection
|
||||
- etc.
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
var serversListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all servers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
service := service.NewServerService(client)
|
||||
servers, err := service.List(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Format and display output
|
||||
formatter, _ := getFormatter(cmd)
|
||||
return formatter.Format(servers)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Service Layer (`internal/service/`)
|
||||
|
||||
**Purpose**: Implement business logic and coordinate API calls
|
||||
|
||||
**Responsibilities**:
|
||||
- Validate business rules
|
||||
- Coordinate multiple API calls if needed
|
||||
- Transform API responses to CLI-friendly format
|
||||
- Handle service-specific error cases
|
||||
|
||||
**Key Files**:
|
||||
- `server.go` - Server operations
|
||||
- `deployment.go` - Deployment operations
|
||||
- `project.go` - Project operations
|
||||
- `resource.go` - Resource operations
|
||||
- `privatekey.go` - SSH key operations
|
||||
- `domain.go` - Domain operations
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
type ServerService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
func (s *ServerService) List(ctx context.Context) ([]models.Server, error) {
|
||||
var servers []models.Server
|
||||
err := s.client.Get(ctx, "servers", &servers)
|
||||
return servers, err
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API Client Layer (`internal/api/`)
|
||||
|
||||
**Purpose**: Handle all HTTP communication with Coolify API
|
||||
|
||||
**Responsibilities**:
|
||||
- Construct HTTP requests
|
||||
- Add authentication headers
|
||||
- Retry failed requests with exponential backoff
|
||||
- Parse HTTP responses
|
||||
- Convert HTTP errors to meaningful error messages
|
||||
|
||||
**Key Files**:
|
||||
- `client.go` - HTTP client implementation
|
||||
- `error.go` - API error handling
|
||||
- `options.go` - Client configuration options
|
||||
|
||||
**Example**:
|
||||
```go
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
retries int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
|
||||
return c.doRequest(ctx, "GET", path, nil, result)
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Configuration Layer (`internal/config/`)
|
||||
|
||||
**Purpose**: Manage CLI configuration and multiple instances
|
||||
|
||||
**Responsibilities**:
|
||||
- Load/save configuration from disk
|
||||
- Manage multiple Coolify instances
|
||||
- Select default instance
|
||||
- Store API tokens securely (file permissions)
|
||||
|
||||
**Key Files**:
|
||||
- `config.go` - Configuration structure and methods
|
||||
- `instance.go` - Instance definition
|
||||
- `loader.go` - File I/O operations
|
||||
|
||||
**Configuration File** (`~/.config/coolify/config.json`):
|
||||
```json
|
||||
{
|
||||
"instances": [
|
||||
{
|
||||
"name": "prod",
|
||||
"fqdn": "https://coolify.example.com",
|
||||
"token": "your-api-token",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"name": "staging",
|
||||
"fqdn": "https://staging.coolify.example.com",
|
||||
"token": "staging-token"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Output Layer (`internal/output/`)
|
||||
|
||||
**Purpose**: Format data for display to users
|
||||
|
||||
**Responsibilities**:
|
||||
- Format data as tables, JSON, or pretty-printed JSON
|
||||
- Hide sensitive information unless `--show-sensitive` is used
|
||||
- Handle different data types (slices, structs, primitives)
|
||||
|
||||
**Key Files**:
|
||||
- `formatter.go` - Formatter interface
|
||||
- `table.go` - Table formatting
|
||||
- `json.go` - JSON formatting
|
||||
- `pretty.go` - Pretty JSON formatting
|
||||
|
||||
**Supported Formats**:
|
||||
- `table` - Default, human-readable tables
|
||||
- `json` - Compact JSON for scripting
|
||||
- `pretty` - Indented JSON for debugging
|
||||
|
||||
### 6. Models Layer (`internal/models/`)
|
||||
|
||||
**Purpose**: Define data structures
|
||||
|
||||
**Responsibilities**:
|
||||
- Define API request/response structures
|
||||
- JSON tags for marshaling
|
||||
- Common types and timestamps
|
||||
|
||||
**Key Files**:
|
||||
- `server.go` - Server-related types
|
||||
- `project.go` - Project-related types
|
||||
- `resource.go` - Resource types
|
||||
- `deployment.go` - Deployment types
|
||||
- `common.go` - Shared types
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Example: Listing Servers
|
||||
|
||||
1. **User Input**: `coolify servers list --format=table`
|
||||
|
||||
2. **Command Layer** (`cmd/servers.go`):
|
||||
- Cobra parses the command
|
||||
- `serversListCmd.RunE` is executed
|
||||
- Gets API client using `getAPIClient()`
|
||||
- Creates ServerService instance
|
||||
|
||||
3. **Service Layer** (`internal/service/server.go`):
|
||||
- `ServerService.List()` is called
|
||||
- Validates context (if needed)
|
||||
- Calls API client
|
||||
|
||||
4. **API Client Layer** (`internal/api/client.go`):
|
||||
- Constructs GET request to `/api/v1/servers`
|
||||
- Adds Bearer token authentication
|
||||
- Sends HTTP request
|
||||
- Retries on failure (with backoff)
|
||||
- Parses JSON response
|
||||
|
||||
5. **Response Processing**:
|
||||
- JSON unmarshaled to `[]models.Server`
|
||||
- Returns to service layer
|
||||
- Returns to command layer
|
||||
|
||||
6. **Output Layer** (`internal/output/table.go`):
|
||||
- Command layer creates table formatter
|
||||
- Formatter processes server data
|
||||
- Formats as table with columns
|
||||
- Writes to stdout
|
||||
|
||||
7. **User Output**: Table displayed in terminal
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### 1. Dependency Injection
|
||||
|
||||
Services receive the API client as a constructor parameter:
|
||||
|
||||
```go
|
||||
func NewServerService(client *api.Client) *ServerService {
|
||||
return &ServerService{client: client}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Easy to test (can inject mock client)
|
||||
- Clear dependencies
|
||||
- Flexible configuration
|
||||
|
||||
### 2. Strategy Pattern (Output Formatters)
|
||||
|
||||
Different formatters implement the same interface:
|
||||
|
||||
```go
|
||||
type Formatter interface {
|
||||
Format(data interface{}) error
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Easy to add new formats
|
||||
- Consistent API
|
||||
- Runtime format selection
|
||||
|
||||
### 3. Options Pattern (API Client)
|
||||
|
||||
Client configuration uses functional options:
|
||||
|
||||
```go
|
||||
client := api.NewClient(url, token,
|
||||
api.WithDebug(true),
|
||||
api.WithRetries(5),
|
||||
api.WithTimeout(60 * time.Second),
|
||||
)
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Optional parameters
|
||||
- Clear intent
|
||||
- Backward compatible
|
||||
|
||||
### 4. Error Wrapping
|
||||
|
||||
Errors are wrapped with context at each layer:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list servers: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Error context preserved
|
||||
- Stack trace maintained
|
||||
- Better debugging
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Each layer has comprehensive unit tests:
|
||||
|
||||
- **Commands**: Mock services, test flag parsing
|
||||
- **Services**: Mock API client, test business logic
|
||||
- **API Client**: Use `httptest.Server`, test HTTP handling
|
||||
- **Config**: Test file I/O with temp directories
|
||||
- **Output**: Test formatting with buffers
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test multiple layers together:
|
||||
|
||||
- Commands + Services + Mock API
|
||||
- Config + File System
|
||||
- End-to-end workflows
|
||||
|
||||
### Coverage Goals
|
||||
|
||||
- Overall: 70%+
|
||||
- New features: 80%+
|
||||
- Critical paths: 90%+
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### CLI Configuration
|
||||
|
||||
**Location**: `~/.config/coolify/config.json` (Linux/macOS)
|
||||
**Location**: `%APPDATA%\coolify\config.json` (Windows)
|
||||
|
||||
**Structure**:
|
||||
```json
|
||||
{
|
||||
"instances": [
|
||||
{
|
||||
"name": "prod",
|
||||
"fqdn": "https://coolify.example.com",
|
||||
"token": "your-token",
|
||||
"default": true
|
||||
}
|
||||
],
|
||||
"lastUpdateCheckTime": "2025-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## API Communication
|
||||
|
||||
### Base URL
|
||||
|
||||
All API calls use: `{fqdn}/api/v1/{endpoint}`
|
||||
|
||||
Example: `https://coolify.example.com/api/v1/servers`
|
||||
|
||||
### Authentication
|
||||
|
||||
Bearer token authentication:
|
||||
|
||||
```
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### Request/Response
|
||||
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
**Request Body** (POST):
|
||||
```json
|
||||
{
|
||||
"name": "my-server",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Body**:
|
||||
```json
|
||||
{
|
||||
"uuid": "abc123",
|
||||
"name": "my-server",
|
||||
"ip": "192.168.1.100"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
HTTP errors are converted to CLI-friendly messages:
|
||||
|
||||
- `401` → "Unauthenticated. Check your API token."
|
||||
- `404` → "Resource not found."
|
||||
- `500` → "Server error. Please try again."
|
||||
|
||||
### Retry Logic
|
||||
|
||||
Failed requests are retried with exponential backoff:
|
||||
|
||||
- Attempt 1: Immediate
|
||||
- Attempt 2: Wait 1s
|
||||
- Attempt 3: Wait 2s
|
||||
- Attempt 4: Wait 4s
|
||||
|
||||
Does not retry on 4xx errors (except 429 rate limit).
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### API Token Storage
|
||||
|
||||
- Stored in config file with restricted permissions (0600)
|
||||
- Never logged (even in debug mode)
|
||||
- Masked in output by default (use `-s` to show)
|
||||
|
||||
### Sensitive Data Handling
|
||||
|
||||
- Tokens masked as `********` in output
|
||||
- Use `--show-sensitive` flag to reveal
|
||||
- Debug logs sanitize sensitive data
|
||||
|
||||
### HTTPS
|
||||
|
||||
- All API communication uses HTTPS
|
||||
- Certificate validation enabled
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
Batch deployments run in parallel:
|
||||
|
||||
```go
|
||||
// Deploy multiple resources concurrently
|
||||
var wg sync.WaitGroup
|
||||
for _, name := range names {
|
||||
wg.Add(1)
|
||||
go func(n string) {
|
||||
defer wg.Done()
|
||||
deployResource(n)
|
||||
}(name)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### Connection Reuse
|
||||
|
||||
HTTP client reuses connections:
|
||||
|
||||
```go
|
||||
c.httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Dependencies
|
||||
|
||||
- Use Go standard library when possible
|
||||
- Only essential external dependencies
|
||||
- Keep binary size small
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding a New Command
|
||||
|
||||
1. Create `cmd/newfeature.go`
|
||||
2. Define Cobra command
|
||||
3. Create service if needed (`internal/service/newfeature.go`)
|
||||
4. Add models if needed (`internal/models/newfeature.go`)
|
||||
5. Register command in `init()`
|
||||
6. Write tests
|
||||
|
||||
### Adding a New Output Format
|
||||
|
||||
1. Create `internal/output/newformat.go`
|
||||
2. Implement `Formatter` interface
|
||||
3. Add format constant
|
||||
4. Update `NewFormatter()` switch
|
||||
|
||||
### Adding API Client Features
|
||||
|
||||
1. Add method to `internal/api/client.go`
|
||||
2. Add tests in `internal/api/client_test.go`
|
||||
3. Use in service layer
|
||||
|
||||
## Build & Release
|
||||
|
||||
### Build Process
|
||||
|
||||
```bash
|
||||
# Local build
|
||||
go build -o coolify .
|
||||
|
||||
# Multi-platform release
|
||||
goreleaser release --clean
|
||||
```
|
||||
|
||||
### Release Artifacts
|
||||
|
||||
- Linux: amd64, arm64
|
||||
- macOS: amd64, arm64 (Apple Silicon)
|
||||
- Windows: amd64
|
||||
|
||||
### Distribution
|
||||
|
||||
- GitHub Releases
|
||||
- Install script: `scripts/install.sh`
|
||||
- Package managers (planned)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Shell completion improvements
|
||||
- [ ] Interactive mode
|
||||
- [ ] Configuration wizard
|
||||
- [ ] Plugin system
|
||||
- [ ] Telemetry (opt-in)
|
||||
- [ ] Cache layer for frequent queries
|
||||
|
||||
## References
|
||||
|
||||
- [Cobra Documentation](https://cobra.dev/)
|
||||
- [Coolify API Specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
|
||||
- [Go Project Layout](https://github.com/golang-standards/project-layout)
|
||||
@@ -1,343 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys.
|
||||
|
||||
### API Specification
|
||||
This CLI is a client for the Coolify API. The API specification is defined in the OpenAPI schema:
|
||||
- **Source**: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
- **Base Path**: `/api/v1/`
|
||||
- **Authentication**: Bearer token (API tokens from Coolify dashboard at `/security/api-tokens`)
|
||||
|
||||
All commands in this CLI are wrappers around API endpoints defined in the OpenAPI specification. When adding new features or endpoints:
|
||||
1. Check the OpenAPI spec for available endpoints and their request/response schemas
|
||||
2. Ensure the CLI command structure follows the API resource hierarchy
|
||||
3. Match the API's data types and validation rules
|
||||
|
||||
## Architecture
|
||||
|
||||
### Command Structure
|
||||
The codebase follows Cobra's command pattern with a root command and subcommands:
|
||||
- Entry point: `main.go` calls `cmd.Execute()`
|
||||
- Root command: `cmd/root.go` - contains core utilities (HTTP client, authentication, version checking, config management)
|
||||
- Subcommands: Each command is in its own file in `cmd/`:
|
||||
- `instances.go` - manage Coolify instances (add, remove, list, set default/token)
|
||||
- `servers.go` - list and get server information
|
||||
- `projects.go` - list projects with environments and applications
|
||||
- `resources.go` - list resources
|
||||
- `deploy.go` - deploy resources
|
||||
- `domains.go` - manage domains
|
||||
- `privatekeys.go` - manage SSH keys
|
||||
- `update.go` - self-update CLI
|
||||
- `version.go` - show CLI version
|
||||
|
||||
### Configuration Management
|
||||
- Uses Viper for configuration management
|
||||
- Config file location: `~/.config/coolify/config.json` (via xdg package)
|
||||
- Config stores multiple instances with tokens, default instance selection
|
||||
- Global flags available: `--token`, `--host`, `--format`, `--show-sensitive`, `--force`, `--debug`
|
||||
|
||||
### API Communication
|
||||
Core API functions in `cmd/root.go`:
|
||||
- `Fetch(url string)` - GET requests
|
||||
- `Post(url, input)` - POST requests
|
||||
- `Delete(url)` - DELETE requests
|
||||
All API calls use `Fqdn + "/api/v1/" + url` pattern with Bearer token authentication
|
||||
|
||||
### Version Management
|
||||
- CLI version tracking with auto-update check (10 minute interval)
|
||||
- API version checking and minimum version enforcement via `CheckMinimumVersion()`
|
||||
- Self-update capability using `go-selfupdate` library
|
||||
|
||||
### Output Formatting
|
||||
Three output modes supported via `--format` flag:
|
||||
- `table` (default) - tabwriter formatted output
|
||||
- `json` - compact JSON
|
||||
- `pretty` - indented JSON
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Build
|
||||
```bash
|
||||
go build -o coolify .
|
||||
```
|
||||
|
||||
### Run locally
|
||||
```bash
|
||||
go run main.go [command]
|
||||
```
|
||||
|
||||
### Test a command
|
||||
```bash
|
||||
go run main.go instances list
|
||||
go run main.go servers list --debug
|
||||
```
|
||||
|
||||
### Install locally
|
||||
```bash
|
||||
go install
|
||||
```
|
||||
|
||||
### Run tests
|
||||
```bash
|
||||
# Run all tests (tests are in internal/ directory)
|
||||
go test ./internal/...
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# Run with verbose output
|
||||
go test ./internal/... -v
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/api/... -v
|
||||
go test ./internal/service/... -v
|
||||
|
||||
# Run specific test
|
||||
go test ./internal/api -run TestClient_Get_Success -v
|
||||
```
|
||||
|
||||
### Before committing
|
||||
```bash
|
||||
# 1. Run tests
|
||||
go test ./internal/...
|
||||
|
||||
# 2. Check coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# 3. Run linter (if available)
|
||||
golangci-lint run
|
||||
|
||||
# 4. Format code
|
||||
go fmt ./...
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
- Uses GoReleaser for multi-platform builds (Linux, Darwin, Windows on amd64/arm64)
|
||||
- Release workflow: `.github/workflows/release-cli.yml` triggers on GitHub releases
|
||||
- GoReleaser config: `.goreleaser.yml`
|
||||
- Install script: `scripts/install.sh` downloads from GitHub releases
|
||||
|
||||
## Key Patterns
|
||||
|
||||
### Adding a New Command
|
||||
1. Create new file in `cmd/` (e.g., `cmd/newfeature.go`)
|
||||
2. Define command struct with cobra.Command
|
||||
3. Implement Run function with:
|
||||
- Call `CheckDefaultThings(nil)` to validate version and format
|
||||
- Use `Fetch()`, `Post()`, or `Delete()` helpers
|
||||
- Handle JSON unmarshaling into typed structs
|
||||
- Support all three output formats
|
||||
4. Register command in `init()` function: `rootCmd.AddCommand(yourCmd)`
|
||||
|
||||
### API Version Requirements
|
||||
If a command requires a specific Coolify API version, pass it to `CheckDefaultThings()`:
|
||||
```go
|
||||
minimumVersion := "4.0.0"
|
||||
CheckDefaultThings(&minimumVersion)
|
||||
```
|
||||
|
||||
### Handling Sensitive Data
|
||||
- Use `ShowSensitive` flag to control display of tokens/secrets
|
||||
- Default overlay: `SensitiveInformationOverlay = "********"`
|
||||
|
||||
### UUID vs ID Pattern
|
||||
**CRITICAL: Always use UUIDs for user-facing interactions, never internal database IDs.**
|
||||
|
||||
When adding new commands or models:
|
||||
1. **Command Arguments**: Always accept UUIDs as string arguments (e.g., `<resource_uuid>`), never integer IDs
|
||||
2. **API Endpoints**: Construct API paths using UUIDs (e.g., `resources/{uuid}`), not IDs
|
||||
3. **Service Layer**: Methods should accept `uuid string` parameters, not `id int`
|
||||
4. **Table Output**: Hide internal IDs from table output using `table:"-"` struct tags
|
||||
5. **Model Fields**:
|
||||
- Keep `ID int` field with `json:"id" table:"-"` (for API responses, hidden from users)
|
||||
- Always include `UUID string` field with `json:"uuid"` (visible to users)
|
||||
|
||||
**Example model:**
|
||||
```go
|
||||
type Resource struct {
|
||||
ID int `json:"id" table:"-"` // Hidden from table output
|
||||
UUID string `json:"uuid"` // Shown in table output
|
||||
Name string `json:"name"`
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
**Why UUIDs?**
|
||||
- UUIDs are stable across environments (dev, staging, prod)
|
||||
- IDs are internal implementation details that can change
|
||||
- UUIDs are more secure (don't expose database sequencing)
|
||||
- Coolify API uses UUIDs as the primary resource identifier
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
**CRITICAL: All code changes MUST include tests. This is non-negotiable.**
|
||||
|
||||
### Test Coverage Requirements
|
||||
- **Minimum coverage**: 70% for all packages
|
||||
- **New features**: Must have 80%+ coverage
|
||||
- **Bug fixes**: Must include regression tests
|
||||
- **Refactoring**: Must maintain or improve existing coverage
|
||||
|
||||
### Testing Structure
|
||||
```
|
||||
test/
|
||||
├── fixtures/ # Test data, mock API responses
|
||||
├── mocks/ # Mock implementations of interfaces
|
||||
└── integration/ # Integration tests with test server
|
||||
```
|
||||
|
||||
### Test Requirements by Package Type
|
||||
|
||||
#### 1. Command Tests (`cmd/*_test.go`)
|
||||
- Test command parsing and flag handling
|
||||
- Test output formatting (table, json, pretty)
|
||||
- Use mock API client to avoid real API calls
|
||||
- Test error handling and validation
|
||||
- Example:
|
||||
```go
|
||||
func TestServersListCmd(t *testing.T) {
|
||||
// Test with mock client
|
||||
// Verify output format
|
||||
// Test error cases
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. API Client Tests (`internal/api/*_test.go`)
|
||||
- Test request building
|
||||
- Test response parsing
|
||||
- Test error handling (4xx, 5xx status codes)
|
||||
- Test retry logic
|
||||
- Test timeout behavior
|
||||
- **IMPORTANT**: Use `httptest.NewServer()` for mock HTTP responses (NOT real APIs)
|
||||
- All API tests must use local mock servers, never call real Coolify cloud or external APIs
|
||||
|
||||
#### 3. Service Tests (`internal/service/*_test.go`)
|
||||
- Test business logic
|
||||
- Mock API client
|
||||
- Test complex workflows
|
||||
- Test error propagation
|
||||
|
||||
#### 4. Model Tests (`internal/models/*_test.go`)
|
||||
- Test JSON marshaling/unmarshaling
|
||||
- Test validation logic
|
||||
- Test helper methods
|
||||
|
||||
#### 5. Integration Tests (`test/integration/*_test.go`)
|
||||
- Test full command execution
|
||||
- Test with real HTTP server (httptest)
|
||||
- Test config file operations
|
||||
- Test version checking
|
||||
- Can be run with `-short` flag to skip
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (tests are in internal/ directory)
|
||||
go test ./internal/...
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/... -cover
|
||||
|
||||
# Generate coverage report
|
||||
go test ./internal/... -coverprofile=coverage.out
|
||||
go tool cover -html=coverage.out
|
||||
|
||||
# Run with verbose output
|
||||
go test ./internal/... -v
|
||||
|
||||
# Run only unit tests (skip integration)
|
||||
go test ./internal/... -short
|
||||
|
||||
# Run specific package
|
||||
go test ./internal/api/... -v
|
||||
go test ./internal/service/... -v
|
||||
```
|
||||
|
||||
### Test Guidelines
|
||||
|
||||
1. **Table-driven tests**: Use for testing multiple scenarios
|
||||
2. **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
|
||||
3. **Subtests**: Use `t.Run()` for related test cases
|
||||
4. **Setup/Teardown**: Use `TestMain()` for package-level setup
|
||||
5. **Parallel tests**: Use `t.Parallel()` when tests are independent
|
||||
6. **Mock dependencies**: Never call real APIs in unit tests
|
||||
7. **Test fixtures**: Store mock API responses in `test/fixtures/`
|
||||
|
||||
### Example Test Structure
|
||||
|
||||
```go
|
||||
func TestServersList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
response: readFixture("servers_list.json"),
|
||||
wantErr: false,
|
||||
wantCount: 3,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
response: "[]",
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "api error",
|
||||
response: `{"error":"unauthorized"}`,
|
||||
wantErr: true,
|
||||
wantCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test implementation
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When Adding a New Command
|
||||
|
||||
**CHECKLIST** (must complete ALL items):
|
||||
- [ ] Create command implementation in `cmd/`
|
||||
- [ ] Create corresponding test file in `internal/service/*_test.go` or `internal/api/*_test.go`
|
||||
- [ ] Test all flags and arguments
|
||||
- [ ] Test all output formats (table, json, pretty)
|
||||
- [ ] Test error cases (missing args, API errors, invalid input)
|
||||
- [ ] Add integration test if command has complex workflow
|
||||
- [ ] Update README.md with command documentation
|
||||
- [ ] Run `go test ./internal/...` and ensure all tests pass
|
||||
- [ ] Verify coverage: `go test ./internal/... -cover`
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Tests run automatically on:
|
||||
- Every pull request
|
||||
- Every commit to main branch
|
||||
- Before releases
|
||||
|
||||
**Pull requests will be blocked if:**
|
||||
- Any test fails
|
||||
- Coverage drops below 70%
|
||||
- New code has no tests
|
||||
|
||||
## .cursorrules Context
|
||||
|
||||
The project follows Go 1.22+ idioms with standard library preference:
|
||||
- Use `net/http` standard library (no external HTTP frameworks)
|
||||
- Leverage Go 1.22 ServeMux features for any routing needs
|
||||
- Follow RESTful patterns for API interactions
|
||||
- Implement proper error handling with custom types when needed
|
||||
- Use Go's concurrency features appropriately
|
||||
- Write secure, efficient, and maintainable code
|
||||
- **ALWAYS write tests** - see Testing Requirements section above
|
||||
@@ -1,141 +0,0 @@
|
||||
# How to Release Coolify CLI
|
||||
|
||||
This guide explains the release process for the Coolify CLI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Write access to the `coollabsio/coolify-cli` repository
|
||||
- All changes merged to the target branch (`v4.x`)
|
||||
- All tests passing (`go test ./internal/...`)
|
||||
|
||||
## Release Process
|
||||
|
||||
### 1. Update Version Number
|
||||
|
||||
Edit `cmd/root.go` and update the `CliVersion` variable:
|
||||
|
||||
```go
|
||||
var CliVersion = "1.x.x" // Change to your new version
|
||||
```
|
||||
|
||||
**Version Format:** Use semantic versioning: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **MAJOR**: Breaking changes
|
||||
- **MINOR**: New features (backwards compatible)
|
||||
- **PATCH**: Bug fixes (backwards compatible)
|
||||
|
||||
### 2. Commit and Push Version Change
|
||||
|
||||
```bash
|
||||
git add cmd/root.go
|
||||
git commit -m "chore: bump version to 1.x.x"
|
||||
git push origin v4.x
|
||||
```
|
||||
|
||||
### 3. Create a GitHub Release
|
||||
|
||||
1. Go to https://github.com/coollabsio/coolify-cli/releases/new
|
||||
2. Click "Choose a tag" and create a new tag:
|
||||
- **Tag name**: `v1.x.x` (must start with `v`, e.g., `v1.2.3`)
|
||||
- **Target**: `v4.x` (or your target branch)
|
||||
3. **Release title**: `v1.x.x` (same as tag name)
|
||||
4. **Description**: Write release notes describing:
|
||||
- New features
|
||||
- Bug fixes
|
||||
- Breaking changes (if any)
|
||||
- Example:
|
||||
```markdown
|
||||
## What's New
|
||||
- Added support for database management
|
||||
- Improved error messages for API failures
|
||||
|
||||
## Bug Fixes
|
||||
- Fixed panic when config file is missing
|
||||
|
||||
## Breaking Changes
|
||||
- None
|
||||
```
|
||||
5. Click "Publish release"
|
||||
|
||||
### 4. Automated Build Process
|
||||
|
||||
Once you publish the release:
|
||||
|
||||
1. GitHub Actions automatically triggers the `release-cli.yml` workflow
|
||||
2. GoReleaser builds binaries for:
|
||||
- **Linux**: amd64, arm64
|
||||
- **macOS (Darwin)**: amd64, arm64
|
||||
- **Windows**: amd64, arm64
|
||||
3. Binaries are automatically uploaded to the release
|
||||
4. The release becomes available at:
|
||||
- GitHub: `https://github.com/coollabsio/coolify-cli/releases/tag/v1.x.x`
|
||||
- Install script: `curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`
|
||||
|
||||
### 5. Verify the Release
|
||||
|
||||
After the workflow completes (usually 2-5 minutes):
|
||||
|
||||
1. Check the release page has all platform binaries
|
||||
2. Test the install script:
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
coolify version
|
||||
```
|
||||
3. Test the auto-update functionality:
|
||||
```bash
|
||||
# If you have an older version installed
|
||||
coolify update
|
||||
coolify version # Should show the new version
|
||||
```
|
||||
4. Verify the version matches your release
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Failed
|
||||
- Check the GitHub Actions logs at https://github.com/coollabsio/coolify-cli/actions
|
||||
- Common issues:
|
||||
- Syntax errors in Go code
|
||||
- Test failures
|
||||
- GoReleaser configuration issues
|
||||
|
||||
### Version Not Updating
|
||||
- Ensure you committed the version change in `cmd/root.go`
|
||||
- The tag must start with `v` (e.g., `v1.2.3`, not `1.2.3`)
|
||||
- Check that the workflow has write permissions
|
||||
|
||||
### Install Script Not Finding New Version
|
||||
- Wait a few minutes for GitHub's CDN to update
|
||||
- Check that binaries were uploaded to the release
|
||||
- Verify the tag format is correct (`v1.x.x`)
|
||||
|
||||
## Release Checklist
|
||||
|
||||
Before creating a release:
|
||||
|
||||
- [ ] All tests pass: `go test ./internal/...`
|
||||
- [ ] Code is formatted: `go fmt ./...`
|
||||
- [ ] Version updated in `cmd/root.go`
|
||||
- [ ] Changes merged to `v4.x` branch
|
||||
- [ ] Release notes prepared
|
||||
|
||||
After creating a release:
|
||||
|
||||
- [ ] GitHub Actions workflow completed successfully
|
||||
- [ ] All platform binaries are present on the release page
|
||||
- [ ] Install script downloads the new version
|
||||
- [ ] `coolify version` returns the correct version
|
||||
|
||||
## Configuration Files
|
||||
|
||||
The release process uses these configuration files:
|
||||
|
||||
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, etc.)
|
||||
- `.github/workflows/release-cli.yml` - GitHub Actions workflow
|
||||
- `scripts/install.sh` - User-facing install script
|
||||
- `cmd/root.go` - Contains `CliVersion` variable (line 22)
|
||||
|
||||
## Notes
|
||||
|
||||
- The CLI has auto-update checking built-in (checks every 10 minutes)
|
||||
- Users can manually update with `coolify update`
|
||||
- Install script supports version pinning: `bash install.sh v1.2.3`
|
||||
- Releases are immutable - if you need to fix something, create a new patch version
|
||||
@@ -1,39 +1,91 @@
|
||||
# CLI for [Coolify](https://coolify.io) API
|
||||
|
||||
> [!WARNING]
|
||||
> Until version 1.0.0, the CLI should be considered unstable. Any minor or patch release may introduce breaking changes. Please read the release notes carefully before updating.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/coollabsio/cli-coolify/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
|
||||
This will install the CLI in `/usr/local/bin/coolify`.
|
||||
|
||||
> If you are a windows or mac user, please test the installation script and let us know if it works for you.
|
||||
> If you are a Windows or macOS user, please test the installation script and let us know if it works for you.
|
||||
|
||||
## Configuration
|
||||
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
|
||||
## Initial Setup
|
||||
|
||||
### Cloud
|
||||
Before using any commands, you need to initialize the CLI by creating a configuration file:
|
||||
|
||||
2. Add the token with `coolify instances set token cloud <token>`
|
||||
```bash
|
||||
coolify init
|
||||
```
|
||||
|
||||
### Self-hosted
|
||||
This interactive wizard will guide you through setting up your Coolify instance(s). You can choose to:
|
||||
- Connect to Coolify Cloud using your API token
|
||||
- Add self-hosted Coolify instance(s) with their FQDN and token
|
||||
|
||||
2. Add the token with `coolify instances add -d <name> <fqdn> <token>`
|
||||
|
||||
> Replace `<name>` with the name you want to give to the instance.
|
||||
>
|
||||
> Replace `<fqdn>` with the fully qualified domain name of your Coolify instance.
|
||||
Alternatively, you can generate a default configuration non-interactively:
|
||||
|
||||
Now you can use the CLI with the token you just added.
|
||||
```bash
|
||||
coolify init --default
|
||||
```
|
||||
|
||||
The configuration will be stored in `~/.config/coolify/config.json`.
|
||||
|
||||
## Getting Your API Token
|
||||
|
||||
To use the CLI, you'll need an API token:
|
||||
1. Log in to your Coolify dashboard (Cloud or self-hosted)
|
||||
2. Navigate to `/security/api-tokens`
|
||||
3. Create a new token with appropriate permissions
|
||||
4. Use this token when initializing the CLI or adding a new instance
|
||||
|
||||
## Managing Instances
|
||||
|
||||
After initialization, you can manage your Coolify instances:
|
||||
|
||||
### Add a New Instance
|
||||
|
||||
```bash
|
||||
coolify instances add MyInstance https://my.instance.tld mytoken
|
||||
```
|
||||
|
||||
Or use the interactive mode:
|
||||
|
||||
```bash
|
||||
coolify instances add
|
||||
```
|
||||
|
||||
### List All Instances
|
||||
|
||||
```bash
|
||||
coolify instances list
|
||||
```
|
||||
|
||||
### Set Default Instance
|
||||
|
||||
```bash
|
||||
coolify instances set default MyInstance
|
||||
```
|
||||
|
||||
### Remove an Instance
|
||||
|
||||
```bash
|
||||
coolify instances remove MyInstance
|
||||
```
|
||||
|
||||
### Update Instance Token
|
||||
|
||||
```bash
|
||||
coolify instances set token MyInstance newtoken
|
||||
```
|
||||
|
||||
## Change default instance
|
||||
You can change the default instance with `coolify instances set default <name>`
|
||||
## Currently Supported Commands
|
||||
|
||||
### Update
|
||||
- `coolify update` - Update the CLI to the latest version
|
||||
|
||||
|
||||
### Instances
|
||||
- `coolify instances list` - List all instances
|
||||
- `coolify instances add` - Create a new instance configuration
|
||||
@@ -44,379 +96,5 @@ You can change the default instance with `coolify instances set default <name>`
|
||||
|
||||
### Servers
|
||||
- `coolify servers list` - List all servers
|
||||
- `coolify servers get <uuid>` - Get a server by UUID
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
- `coolify servers add <name> <ip> <private_key_uuid>` - Add a new server
|
||||
- `--port <port>` - SSH port (default: 22)
|
||||
- `--user <user>` - SSH user (default: root)
|
||||
- `--validate` - Validate server immediately after adding
|
||||
- `coolify servers remove <uuid>` - Remove a server
|
||||
- `coolify servers validate <uuid>` - Validate a server connection
|
||||
|
||||
### Projects
|
||||
- `coolify projects list` - List all projects
|
||||
- `coolify projects get <uuid>` - Get project environments
|
||||
|
||||
### Resources
|
||||
- `coolify resources list` - List all resources
|
||||
|
||||
### Applications
|
||||
- `coolify app list` - List all applications
|
||||
- `coolify app get <uuid>` - Get application details
|
||||
- `coolify app update <uuid>` - Update application configuration
|
||||
- `--name <name>` - Application name
|
||||
- `--description <description>` - Application description
|
||||
- `coolify app delete <uuid>` - Delete an application
|
||||
- `--force` - Skip confirmation prompt
|
||||
- `coolify app start <uuid>` - Start an application
|
||||
- `coolify app stop <uuid>` - Stop an application
|
||||
- `coolify app restart <uuid>` - Restart an application
|
||||
- `coolify app logs <uuid>` - Get application logs
|
||||
|
||||
#### Application Environment Variables
|
||||
- `coolify app env list <app_uuid>` - List all environment variables
|
||||
- `coolify app env get <app_uuid> <env_uuid_or_key>` - Get a specific environment variable
|
||||
- `coolify app env create <app_uuid>` - Create a new environment variable
|
||||
- `--key <key>` - Variable key (required)
|
||||
- `--value <value>` - Variable value (required)
|
||||
- `--is-preview` - Set variable for preview environments
|
||||
- `--is-build-time` - Set variable as build-time variable
|
||||
- `--is-literal` - Treat value as literal (no variable expansion)
|
||||
- `--is-multiline` - Allow multiline values
|
||||
- `--is-shown-once` - Show value only once (for secrets)
|
||||
- `coolify app env update <app_uuid> <env_uuid>` - Update an environment variable
|
||||
- `coolify app env delete <app_uuid> <env_uuid>` - Delete an environment variable
|
||||
- `coolify app env sync <app_uuid>` - Sync environment variables from a .env file
|
||||
- `--file <path>` - Path to .env file (required)
|
||||
|
||||
### Databases
|
||||
- `coolify database list` - List all databases
|
||||
- `coolify database get <uuid>` - Get database details
|
||||
- `coolify database create <type>` - Create a new database
|
||||
- Supported types: `postgresql`, `mysql`, `mariadb`, `mongodb`, `redis`, `keydb`, `clickhouse`, `dragonfly`
|
||||
- `--server-uuid <uuid>` - Server UUID (required)
|
||||
- `--project-uuid <uuid>` - Project UUID (required)
|
||||
- `--name <name>` - Database name
|
||||
- `--description <description>` - Database description
|
||||
- `--image <image>` - Docker image
|
||||
- `--instant-deploy` - Deploy immediately after creation
|
||||
- `--is-public` - Make database publicly accessible
|
||||
- `--public-port <port>` - Public port number
|
||||
- Database-specific flags (postgres-user, mysql-root-password, etc.)
|
||||
- `coolify database update <uuid>` - Update database configuration
|
||||
- `coolify database delete <uuid>` - Delete a database
|
||||
- `--delete-configurations` - Delete configurations (default: true)
|
||||
- `--delete-volumes` - Delete volumes (default: true)
|
||||
- `--docker-cleanup` - Run docker cleanup (default: true)
|
||||
- `coolify database start <uuid>` - Start a database
|
||||
- `coolify database stop <uuid>` - Stop a database
|
||||
- `coolify database restart <uuid>` - Restart a database
|
||||
|
||||
#### Database Backups
|
||||
- `coolify database backup list <database_uuid>` - List all backup configurations
|
||||
- `coolify database backup create <database_uuid>` - Create a new backup configuration
|
||||
- `--frequency <cron>` - Backup frequency (cron expression)
|
||||
- `--enabled` - Enable backup schedule
|
||||
- `--save-s3` - Save backups to S3
|
||||
- `--s3-storage-uuid <uuid>` - S3 storage UUID
|
||||
- `--retention-amount-locally <n>` - Number of backups to retain locally
|
||||
- `--retention-days-locally <n>` - Days to retain backups locally
|
||||
- `--timeout <seconds>` - Backup timeout
|
||||
- `coolify database backup update <database_uuid> <backup_uuid>` - Update a backup configuration
|
||||
- `coolify database backup delete <database_uuid> <backup_uuid>` - Delete a backup configuration
|
||||
- `coolify database backup trigger <database_uuid> <backup_uuid>` - Trigger an immediate backup
|
||||
- `coolify database backup executions <database_uuid> <backup_uuid>` - List backup executions
|
||||
- `coolify database backup delete-execution <database_uuid> <backup_uuid> <execution_uuid>` - Delete a backup execution
|
||||
|
||||
### Services
|
||||
- `coolify service list` - List all services
|
||||
- `coolify service get <uuid>` - Get service details
|
||||
- `coolify service start <uuid>` - Start a service
|
||||
- `coolify service stop <uuid>` - Stop a service
|
||||
- `coolify service restart <uuid>` - Restart a service
|
||||
- `coolify service delete <uuid>` - Delete a service
|
||||
|
||||
#### Service Environment Variables
|
||||
- `coolify service env list <service_uuid>` - List all environment variables
|
||||
- `coolify service env get <service_uuid> <env_uuid_or_key>` - Get a specific environment variable
|
||||
- `coolify service env create <service_uuid>` - Create a new environment variable
|
||||
- Same flags as application environment variables
|
||||
- `coolify service env update <service_uuid> <env_uuid>` - Update an environment variable
|
||||
- `coolify service env delete <service_uuid> <env_uuid>` - Delete an environment variable
|
||||
- `coolify service env sync <service_uuid>` - Sync environment variables from a .env file
|
||||
- `--file <path>` - Path to .env file (required)
|
||||
|
||||
### Deployments
|
||||
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
|
||||
- `--force` - Force deployment
|
||||
- `coolify deploy name <name>` - Deploy a resource by name
|
||||
- `--force` - Force deployment
|
||||
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
|
||||
- `--force` - Force all deployments
|
||||
- `coolify deploy list` - List all deployments
|
||||
- `coolify deploy get <uuid>` - Get deployment details
|
||||
- `coolify deploy cancel <uuid>` - Cancel a deployment
|
||||
- `--force` - Skip confirmation prompt
|
||||
|
||||
### GitHub Apps
|
||||
- `coolify github list` - List all GitHub App integrations
|
||||
- `coolify github get <app_uuid>` - Get GitHub App details
|
||||
- `coolify github create` - Create a new GitHub App integration
|
||||
- `--name <name>` - GitHub App name (required)
|
||||
- `--api-url <url>` - GitHub API URL (required)
|
||||
- `--html-url <url>` - GitHub HTML URL (required)
|
||||
- `--app-id <id>` - GitHub App ID (required)
|
||||
- `--installation-id <id>` - Installation ID (required)
|
||||
- `--client-id <id>` - OAuth Client ID (required)
|
||||
- `--client-secret <secret>` - OAuth Client Secret (required)
|
||||
- `--private-key-uuid <uuid>` - Private key UUID (required)
|
||||
- `--organization <org>` - GitHub organization
|
||||
- `--custom-user <user>` - Custom SSH user
|
||||
- `--custom-port <port>` - Custom SSH port
|
||||
- `--webhook-secret <secret>` - Webhook secret
|
||||
- `--system-wide` - System-wide installation
|
||||
- `coolify github update <app_uuid>` - Update a GitHub App
|
||||
- `coolify github delete <app_uuid>` - Delete a GitHub App
|
||||
- `--force` - Skip confirmation prompt
|
||||
- `coolify github repos <app_uuid>` - List repositories accessible by a GitHub App
|
||||
- `coolify github branches <app_uuid> <owner/repo>` - List branches for a repository
|
||||
|
||||
### Teams
|
||||
- `coolify team list` - List all teams
|
||||
- `coolify team get <id>` - Get team details
|
||||
- `coolify team current` - Get current team
|
||||
- `coolify team members list [team_id]` - List team members
|
||||
|
||||
### Domains
|
||||
- `coolify domains list` - List all domains
|
||||
|
||||
### Private Keys
|
||||
- `coolify privatekeys list` - List all private keys
|
||||
- `coolify privatekeys create <name> <private-key>` - Create a new private key
|
||||
- Use `@filename` to read from file: `coolify privatekeys create mykey @~/.ssh/id_rsa`
|
||||
- `coolify privatekeys delete <uuid>` - Delete a private key
|
||||
|
||||
## Global Flags
|
||||
|
||||
All commands support these global flags:
|
||||
|
||||
- `--instance <name>` - Use a specific instance profile instead of default (NEW)
|
||||
- `--host <fqdn>` - Override the Coolify instance hostname
|
||||
- `--token <token>` - Override the authentication token
|
||||
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
|
||||
- `--show-sensitive` / `-s` - Show sensitive information (tokens, IPs, etc.)
|
||||
- `--force` / `-f` - Force operation (skip confirmations)
|
||||
- `--debug` - Enable debug mode
|
||||
|
||||
## Examples
|
||||
|
||||
### Multi-Environment Workflows
|
||||
|
||||
```bash
|
||||
# Add multiple instances
|
||||
coolify instances add prod https://prod.coolify.io <prod-token>
|
||||
coolify instances add staging https://staging.coolify.io <staging-token>
|
||||
coolify instances add dev https://dev.coolify.io <dev-token>
|
||||
|
||||
# Set default
|
||||
coolify instances set default prod
|
||||
|
||||
# Use different profiles
|
||||
coolify --instance=staging servers list
|
||||
coolify --instance=prod deploy name api
|
||||
coolify --instance=dev resources list
|
||||
|
||||
# Default profile (prod in this case)
|
||||
coolify servers list
|
||||
```
|
||||
|
||||
### Application Management
|
||||
|
||||
```bash
|
||||
# List all applications
|
||||
coolify app list
|
||||
|
||||
# Get application details
|
||||
coolify app get <uuid>
|
||||
|
||||
# Manage application lifecycle
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
|
||||
# View application logs
|
||||
coolify app logs <uuid>
|
||||
|
||||
# Environment variables
|
||||
coolify app env list <uuid>
|
||||
coolify app env create <uuid> --key API_KEY --value secret123
|
||||
coolify app env sync <uuid> --file .env
|
||||
```
|
||||
|
||||
### Database Management
|
||||
|
||||
```bash
|
||||
# List databases
|
||||
coolify database list
|
||||
|
||||
# Create a PostgreSQL database
|
||||
coolify database create postgresql \
|
||||
--server-uuid <server-uuid> \
|
||||
--project-uuid <project-uuid> \
|
||||
--name mydb \
|
||||
--instant-deploy
|
||||
|
||||
# Manage database lifecycle
|
||||
coolify database start <uuid>
|
||||
coolify database stop <uuid>
|
||||
coolify database restart <uuid>
|
||||
|
||||
# Backup management
|
||||
coolify database backup list <database-uuid>
|
||||
coolify database backup create <database-uuid> \
|
||||
--frequency "0 2 * * *" \
|
||||
--enabled \
|
||||
--save-s3 \
|
||||
--retention-days-locally 7
|
||||
coolify database backup trigger <database-uuid> <backup-uuid>
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
```bash
|
||||
# List services
|
||||
coolify service list
|
||||
|
||||
# Get service details
|
||||
coolify service get <uuid>
|
||||
|
||||
# Manage services
|
||||
coolify service start <uuid>
|
||||
coolify service restart <uuid>
|
||||
|
||||
# Environment variables (same as applications)
|
||||
coolify service env sync <uuid> --file .env
|
||||
```
|
||||
|
||||
### Deploy Workflows
|
||||
|
||||
```bash
|
||||
# Deploy single app by name (easier than UUID)
|
||||
coolify deploy name my-application
|
||||
|
||||
# Deploy multiple apps at once
|
||||
coolify deploy batch api,worker,frontend
|
||||
|
||||
# Force deploy with specific profile
|
||||
coolify --instance=prod deploy batch api,worker --force
|
||||
|
||||
# Traditional UUID deployment still works
|
||||
coolify deploy uuid abc123-def456-...
|
||||
|
||||
# Monitor deployments
|
||||
coolify deploy list
|
||||
coolify deploy get <deployment-uuid>
|
||||
|
||||
# Cancel a deployment
|
||||
coolify deploy cancel <deployment-uuid>
|
||||
```
|
||||
|
||||
### GitHub Apps Integration
|
||||
|
||||
```bash
|
||||
# List GitHub Apps
|
||||
coolify github list
|
||||
|
||||
# Create a GitHub App integration
|
||||
coolify github create \
|
||||
--name "My GitHub App" \
|
||||
--api-url "https://api.github.com" \
|
||||
--html-url "https://github.com" \
|
||||
--app-id 123456 \
|
||||
--installation-id 789012 \
|
||||
--client-id "Iv1.abc123" \
|
||||
--client-secret "secret" \
|
||||
--private-key-uuid <key-uuid>
|
||||
|
||||
# List repositories accessible by the app
|
||||
coolify github repos <app-uuid>
|
||||
|
||||
# List branches for a repository
|
||||
coolify github branches <app-uuid> owner/repo
|
||||
|
||||
# Delete a GitHub App
|
||||
coolify github delete <app-uuid>
|
||||
```
|
||||
|
||||
### Team Management
|
||||
|
||||
```bash
|
||||
# List teams
|
||||
coolify team list
|
||||
|
||||
# Get current team
|
||||
coolify team current
|
||||
|
||||
# List team members
|
||||
coolify team members list
|
||||
```
|
||||
|
||||
### Server Management
|
||||
|
||||
```bash
|
||||
# List servers in production
|
||||
coolify --instance=prod servers list
|
||||
|
||||
# Add a server with validation
|
||||
coolify servers add myserver 192.168.1.100 <key-uuid> --validate
|
||||
|
||||
# Get server details with resources
|
||||
coolify servers get <uuid> --resources
|
||||
```
|
||||
|
||||
## Output Formats
|
||||
|
||||
The CLI supports three output formats:
|
||||
|
||||
```bash
|
||||
# Table format (default, human-readable)
|
||||
coolify servers list
|
||||
|
||||
# JSON format (for scripts)
|
||||
coolify servers list --format=json
|
||||
|
||||
# Pretty JSON (for debugging)
|
||||
coolify servers list --format=pretty
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This CLI follows a clean architecture with:
|
||||
- **Service Layer**: Business logic and API interactions
|
||||
- **Output Layer**: Consistent formatting across all commands
|
||||
- **Config Layer**: Multi-instance configuration management
|
||||
- **Models Layer**: Type-safe data structures
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build -o coolify .
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run with coverage
|
||||
go test -cover ./...
|
||||
|
||||
# Install locally
|
||||
go install
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please check the [restructure documentation](RESTRUCTURE_PLAN.md) for architecture guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
- `coolify servers get` - Get a server
|
||||
- `--resources` - Get the resources and their status of a server
|
||||
@@ -1,910 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var applicationsCmd = &cobra.Command{
|
||||
Use: "app",
|
||||
Aliases: []string{"apps", "application", "applications"},
|
||||
Short: "Application related commands",
|
||||
Long: `Manage Coolify applications - list, get, create, update, delete, and control application lifecycle.`,
|
||||
}
|
||||
|
||||
var listApplicationsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all applications",
|
||||
Long: `List all applications in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
apps, err := appSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list applications: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full application structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(apps)
|
||||
}
|
||||
|
||||
// For table format, convert to simplified rows
|
||||
var rows []models.ApplicationListItem
|
||||
for _, app := range apps {
|
||||
rows = append(rows, models.ApplicationListItem{
|
||||
UUID: app.UUID,
|
||||
Name: app.Name,
|
||||
Description: app.Description,
|
||||
Status: app.Status,
|
||||
GitBranch: app.GitBranch,
|
||||
FQDN: app.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
|
||||
var getApplicationCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get application details by UUID",
|
||||
Long: `Retrieve detailed information about a specific application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get application: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var updateApplicationCmd = &cobra.Command{
|
||||
Use: "update <uuid>",
|
||||
Short: "Update application configuration",
|
||||
Long: `Update configuration for a specific application. Only specified fields will be updated.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Build update request from flags
|
||||
req := models.ApplicationUpdateRequest{}
|
||||
hasUpdates := false
|
||||
|
||||
// Basic configuration
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
desc, _ := cmd.Flags().GetString("description")
|
||||
req.Description = &desc
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("git-branch") {
|
||||
branch, _ := cmd.Flags().GetString("git-branch")
|
||||
req.GitBranch = &branch
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("git-repository") {
|
||||
repo, _ := cmd.Flags().GetString("git-repository")
|
||||
req.GitRepository = &repo
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("domains") {
|
||||
domains, _ := cmd.Flags().GetString("domains")
|
||||
req.Domains = &domains
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Build configuration
|
||||
if cmd.Flags().Changed("build-command") {
|
||||
buildCmd, _ := cmd.Flags().GetString("build-command")
|
||||
req.BuildCommand = &buildCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("start-command") {
|
||||
startCmd, _ := cmd.Flags().GetString("start-command")
|
||||
req.StartCommand = &startCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("install-command") {
|
||||
installCmd, _ := cmd.Flags().GetString("install-command")
|
||||
req.InstallCommand = &installCmd
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("base-directory") {
|
||||
baseDir, _ := cmd.Flags().GetString("base-directory")
|
||||
req.BaseDirectory = &baseDir
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("publish-directory") {
|
||||
publishDir, _ := cmd.Flags().GetString("publish-directory")
|
||||
req.PublishDirectory = &publishDir
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Docker configuration
|
||||
if cmd.Flags().Changed("dockerfile") {
|
||||
dockerfile, _ := cmd.Flags().GetString("dockerfile")
|
||||
req.Dockerfile = &dockerfile
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("docker-image") {
|
||||
image, _ := cmd.Flags().GetString("docker-image")
|
||||
req.DockerRegistryImageName = &image
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("docker-tag") {
|
||||
tag, _ := cmd.Flags().GetString("docker-tag")
|
||||
req.DockerRegistryImageTag = &tag
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Ports
|
||||
if cmd.Flags().Changed("ports-exposes") {
|
||||
ports, _ := cmd.Flags().GetString("ports-exposes")
|
||||
req.PortsExposes = &ports
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("ports-mappings") {
|
||||
ports, _ := cmd.Flags().GetString("ports-mappings")
|
||||
req.PortsMappings = &ports
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Health check
|
||||
if cmd.Flags().Changed("health-check-enabled") {
|
||||
enabled, _ := cmd.Flags().GetBool("health-check-enabled")
|
||||
req.HealthCheckEnabled = &enabled
|
||||
hasUpdates = true
|
||||
}
|
||||
if cmd.Flags().Changed("health-check-path") {
|
||||
path, _ := cmd.Flags().GetString("health-check-path")
|
||||
req.HealthCheckPath = &path
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if !hasUpdates {
|
||||
return fmt.Errorf("no fields to update. Use --help to see available flags")
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
app, err := appSvc.Update(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update application: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var deleteApplicationCmd = &cobra.Command{
|
||||
Use: "delete <uuid>",
|
||||
Short: "Delete an application",
|
||||
Long: `Delete an application. This action cannot be undone.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete application %s? This cannot be undone. (yes/no): ", uuid)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
err = appSvc.Delete(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Application %s deleted successfully.\n", uuid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Define update command flags (most common ones)
|
||||
updateApplicationCmd.Flags().String("name", "", "Application name")
|
||||
updateApplicationCmd.Flags().String("description", "", "Application description")
|
||||
updateApplicationCmd.Flags().String("git-branch", "", "Git branch")
|
||||
updateApplicationCmd.Flags().String("git-repository", "", "Git repository URL")
|
||||
updateApplicationCmd.Flags().String("domains", "", "Domains (comma-separated)")
|
||||
updateApplicationCmd.Flags().String("build-command", "", "Build command")
|
||||
updateApplicationCmd.Flags().String("start-command", "", "Start command")
|
||||
updateApplicationCmd.Flags().String("install-command", "", "Install command")
|
||||
updateApplicationCmd.Flags().String("base-directory", "", "Base directory")
|
||||
updateApplicationCmd.Flags().String("publish-directory", "", "Publish directory")
|
||||
updateApplicationCmd.Flags().String("dockerfile", "", "Dockerfile content")
|
||||
updateApplicationCmd.Flags().String("docker-image", "", "Docker image name")
|
||||
updateApplicationCmd.Flags().String("docker-tag", "", "Docker image tag")
|
||||
updateApplicationCmd.Flags().String("ports-exposes", "", "Exposed ports")
|
||||
updateApplicationCmd.Flags().String("ports-mappings", "", "Port mappings")
|
||||
updateApplicationCmd.Flags().Bool("health-check-enabled", false, "Enable health check")
|
||||
updateApplicationCmd.Flags().String("health-check-path", "", "Health check path")
|
||||
|
||||
// Define delete command flags
|
||||
deleteApplicationCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
// Define start command flags
|
||||
startApplicationCmd.Flags().Bool("force", false, "Force rebuild")
|
||||
startApplicationCmd.Flags().Bool("instant-deploy", false, "Instant deploy (skip queuing)")
|
||||
|
||||
// Define logs command flags
|
||||
logsApplicationCmd.Flags().IntP("lines", "n", 100, "Number of log lines to retrieve")
|
||||
logsApplicationCmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
|
||||
|
||||
// Define envs create command flags
|
||||
createEnvCmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
createEnvCmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
createEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
createEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
createEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
createEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs update command flags
|
||||
updateEnvCmd.Flags().String("key", "", "New environment variable key")
|
||||
updateEnvCmd.Flags().String("value", "", "New environment variable value")
|
||||
updateEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
updateEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
updateEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
updateEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs delete command flags
|
||||
deleteEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
// Define envs sync command flags
|
||||
syncEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
syncEnvCmd.Flags().Bool("build-time", false, "Make all variables available at build time")
|
||||
syncEnvCmd.Flags().Bool("preview", false, "Make all variables available in preview deployments")
|
||||
syncEnvCmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
|
||||
rootCmd.AddCommand(applicationsCmd)
|
||||
applicationsCmd.AddCommand(listApplicationsCmd)
|
||||
applicationsCmd.AddCommand(getApplicationCmd)
|
||||
applicationsCmd.AddCommand(updateApplicationCmd)
|
||||
applicationsCmd.AddCommand(deleteApplicationCmd)
|
||||
applicationsCmd.AddCommand(startApplicationCmd)
|
||||
applicationsCmd.AddCommand(stopApplicationCmd)
|
||||
applicationsCmd.AddCommand(restartApplicationCmd)
|
||||
applicationsCmd.AddCommand(logsApplicationCmd)
|
||||
applicationsCmd.AddCommand(envsApplicationCmd)
|
||||
envsApplicationCmd.AddCommand(listEnvsCmd)
|
||||
envsApplicationCmd.AddCommand(getEnvCmd)
|
||||
envsApplicationCmd.AddCommand(createEnvCmd)
|
||||
envsApplicationCmd.AddCommand(updateEnvCmd)
|
||||
envsApplicationCmd.AddCommand(deleteEnvCmd)
|
||||
envsApplicationCmd.AddCommand(syncEnvCmd)
|
||||
}
|
||||
|
||||
var startApplicationCmd = &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Aliases: []string{"deploy"},
|
||||
Short: "Start an application",
|
||||
Long: `Start an application (initiates a deployment).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
instantDeploy, _ := cmd.Flags().GetBool("instant-deploy")
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Start(ctx, uuid, force, instantDeploy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
if resp.DeploymentUUID != nil && *resp.DeploymentUUID != "" {
|
||||
fmt.Printf("Deployment UUID: %s\n", *resp.DeploymentUUID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stopApplicationCmd = &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop an application",
|
||||
Long: `Stop a running application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Stop(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var restartApplicationCmd = &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart an application",
|
||||
Long: `Restart a running application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
resp, err := appSvc.Restart(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart application: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var logsApplicationCmd = &cobra.Command{
|
||||
Use: "logs <uuid>",
|
||||
Short: "Get application logs",
|
||||
Long: `Retrieve logs for an application. Use --follow to continuously stream new logs.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
lines, _ := cmd.Flags().GetInt("lines")
|
||||
follow, _ := cmd.Flags().GetBool("follow")
|
||||
appSvc := service.NewApplicationService(client)
|
||||
|
||||
if !follow {
|
||||
// One-time fetch
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
fmt.Print(resp.Logs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Follow mode: poll for new logs
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up signal handling for graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
// Track the last log content to avoid duplicates
|
||||
lastLogs := ""
|
||||
|
||||
// Fetch initial logs
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get logs: %w", err)
|
||||
}
|
||||
fmt.Print(resp.Logs)
|
||||
lastLogs = resp.Logs
|
||||
|
||||
// Poll for new logs
|
||||
for {
|
||||
select {
|
||||
case <-sigChan:
|
||||
fmt.Println("\nStopping log follow...")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
resp, err := appSvc.Logs(ctx, uuid, lines)
|
||||
if err != nil {
|
||||
// Don't fail on transient errors in follow mode
|
||||
continue
|
||||
}
|
||||
// Only print if logs have changed
|
||||
if resp.Logs != lastLogs {
|
||||
// Print only the new content
|
||||
if len(resp.Logs) > len(lastLogs) && strings.HasPrefix(resp.Logs, lastLogs) {
|
||||
fmt.Print(resp.Logs[len(lastLogs):])
|
||||
} else {
|
||||
// Logs were truncated or changed, print all
|
||||
fmt.Print(resp.Logs)
|
||||
}
|
||||
lastLogs = resp.Logs
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var envsApplicationCmd = &cobra.Command{
|
||||
Use: "env",
|
||||
Aliases: []string{"envs", "environment"},
|
||||
Short: "Manage application environment variables",
|
||||
Long: `List and manage environment variables for applications. All commands require the application UUID first to establish context.`,
|
||||
}
|
||||
|
||||
var listEnvsCmd = &cobra.Command{
|
||||
Use: "list <app_uuid>",
|
||||
Short: "List all environment variables for an application",
|
||||
Long: `List all environment variables for a specific application.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
envs, err := appSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list environment variables: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive values unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
for i := range envs {
|
||||
envs[i].Value = "********"
|
||||
if envs[i].RealValue != nil {
|
||||
masked := "********"
|
||||
envs[i].RealValue = &masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
|
||||
var getEnvCmd = &cobra.Command{
|
||||
Use: "get <app_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable by UUID or key name.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.GetEnv(ctx, appUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get environment variable: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive value unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
env.Value = "********"
|
||||
if env.RealValue != nil {
|
||||
masked := "********"
|
||||
env.RealValue = &masked
|
||||
}
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(env)
|
||||
},
|
||||
}
|
||||
|
||||
var createEnvCmd = &cobra.Command{
|
||||
Use: "create <app_uuid>",
|
||||
Short: "Create an environment variable for an application",
|
||||
Long: `Create a new environment variable for a specific application. Use --key and --value flags to specify the variable.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// Only set flags if they were explicitly provided
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
fmt.Printf("UUID: %s\n", env.UUID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var updateEnvCmd = &cobra.Command{
|
||||
Use: "update <app_uuid> <env_uuid>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. First UUID is the application, second is the specific environment variable to update.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error{
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: envUUID,
|
||||
}
|
||||
|
||||
// Only set fields that were provided
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
}
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
}
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
// Check if at least one field is being updated
|
||||
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil {
|
||||
return fmt.Errorf("at least one field must be provided to update (--key, --value, --build-time, --preview, --is-literal, or --is-multiline)")
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
env, err := appSvc.UpdateEnv(ctx, appUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteEnvCmd = &cobra.Command{
|
||||
Use: "delete <app_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from an application. First UUID is the application, second is the specific environment variable to delete.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
appSvc := service.NewApplicationService(client)
|
||||
err = appSvc.DeleteEnv(ctx, appUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var syncEnvCmd = &cobra.Command{
|
||||
Use: "sync <app_uuid>",
|
||||
Short: "Sync environment variables from a .env file",
|
||||
Long: `Sync environment variables from a .env file. This command intelligently:
|
||||
- Updates existing environment variables with new values
|
||||
- Creates new environment variables that don't exist yet
|
||||
- Uses efficient bulk operations where possible
|
||||
|
||||
Example: coolify app env sync abc123 --file .env.production`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--file is required")
|
||||
}
|
||||
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
|
||||
// Parse the .env file
|
||||
envVars, err := parser.ParseEnvFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .env file: %w", err)
|
||||
}
|
||||
|
||||
if len(envVars) == 0 {
|
||||
fmt.Println("No environment variables found in file.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
|
||||
|
||||
// Fetch existing environment variables
|
||||
appSvc := service.NewApplicationService(client)
|
||||
existingEnvs, err := appSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list existing environment variables: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of existing env vars by key
|
||||
existingMap := make(map[string]models.EnvironmentVariable)
|
||||
for _, env := range existingEnvs {
|
||||
existingMap[env.Key] = env
|
||||
}
|
||||
|
||||
// Separate into updates and creates
|
||||
var toUpdate []models.EnvironmentVariableCreateRequest
|
||||
var toCreate []models.EnvironmentVariableCreateRequest
|
||||
|
||||
for _, envVar := range envVars {
|
||||
req := models.EnvironmentVariableCreateRequest{
|
||||
Key: envVar.Key,
|
||||
Value: envVar.Value,
|
||||
}
|
||||
|
||||
// Apply flags if explicitly provided
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
|
||||
// Auto-detect multiline values
|
||||
if strings.Contains(envVar.Value, "\n") {
|
||||
multiline := true
|
||||
req.IsMultiline = &multiline
|
||||
}
|
||||
|
||||
if _, exists := existingMap[envVar.Key]; exists {
|
||||
toUpdate = append(toUpdate, req)
|
||||
} else {
|
||||
toCreate = append(toCreate, req)
|
||||
}
|
||||
}
|
||||
|
||||
updateCount := 0
|
||||
createCount := 0
|
||||
failCount := 0
|
||||
|
||||
// Perform bulk update if there are vars to update
|
||||
if len(toUpdate) > 0 {
|
||||
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
|
||||
bulkReq := &service.BulkUpdateEnvsRequest{
|
||||
Data: toUpdate,
|
||||
}
|
||||
_, err := appSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
|
||||
failCount += len(toUpdate)
|
||||
} else {
|
||||
updateCount = len(toUpdate)
|
||||
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new variables one by one
|
||||
if len(toCreate) > 0 {
|
||||
fmt.Printf("Creating %d new variables...\n", len(toCreate))
|
||||
for _, req := range toCreate {
|
||||
_, err := appSvc.CreateEnv(ctx, uuid, &req)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
|
||||
failCount++
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created '%s'\n", req.Key)
|
||||
createCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
|
||||
|
||||
if failCount > 0 {
|
||||
return fmt.Errorf("some environment variables failed to sync")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,466 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplicationsListCmd_Flags(t *testing.T) {
|
||||
cmd := listApplicationsCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
}
|
||||
|
||||
func TestApplicationsGetCmd_Args(t *testing.T) {
|
||||
cmd := getApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsGetCmd_Flags(t *testing.T) {
|
||||
cmd := getApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "get <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsUpdateCmd_Args(t *testing.T) {
|
||||
cmd := updateApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsUpdateCmd_Flags(t *testing.T) {
|
||||
cmd := updateApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "update <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify key flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("name"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("description"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("git-branch"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("domains"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-command"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("start-command"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("docker-image"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("health-check-enabled"))
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_Structure(t *testing.T) {
|
||||
// Verify parent command exists
|
||||
assert.Equal(t, "applications", applicationsCmd.Use)
|
||||
assert.NotEmpty(t, applicationsCmd.Short)
|
||||
|
||||
// Verify subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasUpdateCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
if cmd.Use == "list" {
|
||||
hasListCmd = true
|
||||
}
|
||||
if cmd.Use == "get <uuid>" {
|
||||
hasGetCmd = true
|
||||
}
|
||||
if cmd.Use == "update <uuid>" {
|
||||
hasUpdateCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasUpdateCmd, "update subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsDeleteCmd_Flags(t *testing.T) {
|
||||
cmd := deleteApplicationCmd
|
||||
|
||||
// Verify command structure
|
||||
assert.Equal(t, "delete <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify force flag exists
|
||||
forceFlag := cmd.Flags().Lookup("force")
|
||||
assert.NotNil(t, forceFlag)
|
||||
assert.Equal(t, "false", forceFlag.DefValue)
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_AllSubcommands(t *testing.T) {
|
||||
// Verify all subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasUpdateCmd := false
|
||||
hasDeleteCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
switch cmd.Use {
|
||||
case "list":
|
||||
hasListCmd = true
|
||||
case "get <uuid>":
|
||||
hasGetCmd = true
|
||||
case "update <uuid>":
|
||||
hasUpdateCmd = true
|
||||
case "delete <uuid>":
|
||||
hasDeleteCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasUpdateCmd, "update subcommand should be registered")
|
||||
assert.True(t, hasDeleteCmd, "delete subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsStartCmd_Args(t *testing.T) {
|
||||
cmd := startApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsStartCmd_Structure(t *testing.T) {
|
||||
cmd := startApplicationCmd
|
||||
|
||||
assert.Equal(t, "start <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify aliases exist
|
||||
assert.Contains(t, cmd.Aliases, "deploy")
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("instant-deploy"))
|
||||
}
|
||||
|
||||
func TestApplicationsStopCmd_Args(t *testing.T) {
|
||||
cmd := stopApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsStopCmd_Structure(t *testing.T) {
|
||||
cmd := stopApplicationCmd
|
||||
|
||||
assert.Equal(t, "stop <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsRestartCmd_Args(t *testing.T) {
|
||||
cmd := restartApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsRestartCmd_Structure(t *testing.T) {
|
||||
cmd := restartApplicationCmd
|
||||
|
||||
assert.Equal(t, "restart <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsLogsCmd_Args(t *testing.T) {
|
||||
cmd := logsApplicationCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsLogsCmd_Structure(t *testing.T) {
|
||||
cmd := logsApplicationCmd
|
||||
|
||||
assert.Equal(t, "logs <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("lines"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("follow"))
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_AllLifecycleCommands(t *testing.T) {
|
||||
// Verify all lifecycle subcommands are registered
|
||||
hasStartCmd := false
|
||||
hasStopCmd := false
|
||||
hasRestartCmd := false
|
||||
hasLogsCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
switch cmd.Use {
|
||||
case "start <uuid>":
|
||||
hasStartCmd = true
|
||||
case "stop <uuid>":
|
||||
hasStopCmd = true
|
||||
case "restart <uuid>":
|
||||
hasRestartCmd = true
|
||||
case "logs <uuid>":
|
||||
hasLogsCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasStartCmd, "start subcommand should be registered")
|
||||
assert.True(t, hasStopCmd, "stop subcommand should be registered")
|
||||
assert.True(t, hasRestartCmd, "restart subcommand should be registered")
|
||||
assert.True(t, hasLogsCmd, "logs subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCmd_Structure(t *testing.T) {
|
||||
cmd := envsApplicationCmd
|
||||
|
||||
assert.Equal(t, "envs", cmd.Use)
|
||||
assert.NotNil(t, cmd.Commands())
|
||||
assert.Greater(t, len(cmd.Commands()), 0, "envs should have subcommands")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsListCmd_Args(t *testing.T) {
|
||||
cmd := listEnvsCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsListCmd_Structure(t *testing.T) {
|
||||
cmd := listEnvsCmd
|
||||
|
||||
assert.Equal(t, "list <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestApplicationsCmd_HasEnvsSubcommand(t *testing.T) {
|
||||
// Verify envs subcommand is registered
|
||||
hasEnvsCmd := false
|
||||
|
||||
for _, cmd := range applicationsCmd.Commands() {
|
||||
if cmd.Use == "envs" {
|
||||
hasEnvsCmd = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasEnvsCmd, "envs subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCreateCmd_Args(t *testing.T) {
|
||||
cmd := createEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsCreateCmd_Structure(t *testing.T) {
|
||||
cmd := createEnvCmd
|
||||
|
||||
assert.Equal(t, "create <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("key"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("value"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-multiline"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsUpdateCmd_Args(t *testing.T) {
|
||||
cmd := updateEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with 1 argument - should fail
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123", "env-uuid-456"})
|
||||
assert.NoError(t, err, "should accept 2 arguments")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2", "uuid3"})
|
||||
assert.Error(t, err, "should not accept more than 2 arguments")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsUpdateCmd_Structure(t *testing.T) {
|
||||
cmd := updateEnvCmd
|
||||
|
||||
assert.Equal(t, "update <app_uuid> <env_uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("key"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("value"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-multiline"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with 1 argument - should fail
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.Error(t, err, "should require exactly 2 arguments")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123", "env-uuid-456"})
|
||||
assert.NoError(t, err, "should accept 2 arguments")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2", "uuid3"})
|
||||
assert.Error(t, err, "should not accept more than 2 arguments")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsDeleteCmd_Structure(t *testing.T) {
|
||||
cmd := deleteEnvCmd
|
||||
|
||||
assert.Equal(t, "delete <app_uuid> <env_uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsImportCmd_Args(t *testing.T) {
|
||||
cmd := importEnvCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"app-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestApplicationsEnvsImportCmd_Structure(t *testing.T) {
|
||||
cmd := importEnvCmd
|
||||
|
||||
assert.Equal(t, "import <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("file"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("build-time"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("preview"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("is-literal"))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package ask
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PromptYesOrNo(question string, defaultToYes bool) (bool, error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
if defaultToYes {
|
||||
fmt.Fprintf(os.Stderr, "%s [Y/n]: ", question)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N]: ", question)
|
||||
}
|
||||
for {
|
||||
answer, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||
return defaultToYes, err
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
switch answer {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
case "":
|
||||
return defaultToYes, nil
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Please answer with 'y' or 'n': ")
|
||||
}
|
||||
}
|
||||
|
||||
func PromptString(question string) (string, error) {
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
fmt.Fprintf(os.Stderr, "%s: ", question)
|
||||
|
||||
answer, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(answer), nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cliinit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type cliInit struct {
|
||||
coolify runtime.Getter
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliInit {
|
||||
return &cliInit{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
var defaultInstances = []coolTypes.Instance{
|
||||
{
|
||||
Name: "cloud",
|
||||
Default: true,
|
||||
Fqdn: "https://app.coolify.io",
|
||||
Token: "",
|
||||
}, {
|
||||
Name: "localhost",
|
||||
Fqdn: "http://localhost:8000",
|
||||
Token: "",
|
||||
},
|
||||
}
|
||||
|
||||
func (c *cliInit) NewCommand() *cobra.Command {
|
||||
generateDefault := false
|
||||
force := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s init
|
||||
%[1]s init --default
|
||||
%[1]s init --force
|
||||
`),
|
||||
Short: "Initialize a new Coolify CLI configuration file",
|
||||
Long: `
|
||||
Initialize Coolify CLI by generating a configuration file in the default directory.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.NoArgs,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if c.coolify().Config.JsonExists && !force {
|
||||
return errors.New("configuration file already exists. Please use instances command to make further modifications or force flag to regenerate a new configuration file")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if generateDefault {
|
||||
viper.Set("instances", defaultInstances)
|
||||
cmd.Println("Configuration file generated with default instances, use the instances command to make further modifications.")
|
||||
return c.coolify().Save()
|
||||
}
|
||||
|
||||
// Create a channel to receive the instances
|
||||
result := make(chan []coolTypes.Instance)
|
||||
p := tea.NewProgram(newInitModel(result))
|
||||
|
||||
// Create a done channel to signal when the program is finished
|
||||
done := make(chan struct{})
|
||||
var programErr error
|
||||
|
||||
// Run the program in a goroutine
|
||||
go func() {
|
||||
_, programErr = p.Run()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait for either the instances or context cancellation
|
||||
var instances []coolTypes.Instance
|
||||
select {
|
||||
case instances = <-result:
|
||||
case <-cmd.Context().Done():
|
||||
return fmt.Errorf("operation cancelled")
|
||||
case <-done:
|
||||
if programErr != nil {
|
||||
return fmt.Errorf("program error: %v", programErr)
|
||||
}
|
||||
return fmt.Errorf("program exited without saving instances")
|
||||
}
|
||||
|
||||
viper.Set("instances", instances)
|
||||
return c.coolify().Save()
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&generateDefault, "default", "d", false, "Generate a default configuration file (non-interactive)")
|
||||
flags.BoolVarP(&force, "force", "f", false, "Force the generation of a new configuration file")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package cliinit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
)
|
||||
|
||||
var (
|
||||
checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
|
||||
checked = checkboxStyle.Render("[x]")
|
||||
unchecked = checkboxStyle.Render("[ ]")
|
||||
goldStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
|
||||
)
|
||||
|
||||
// initKeyMap defines keybindings for the initialization form
|
||||
type initKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Tab key.Binding
|
||||
Space key.Binding
|
||||
Enter key.Binding
|
||||
Paste key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp returns keybindings to be shown in the mini help view
|
||||
func (k initKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Help, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp returns keybindings for the expanded help view
|
||||
func (k initKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Tab}, // first column
|
||||
{k.Space, k.Enter, k.Paste, k.Help}, // second column
|
||||
{k.Quit}, // third column
|
||||
}
|
||||
}
|
||||
|
||||
var initKeys = initKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "move down"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
Space: key.NewBinding(
|
||||
key.WithKeys(" "),
|
||||
key.WithHelp("space", "toggle checkbox"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "continue"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+c"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
type initModel struct {
|
||||
instances []coolTypes.Instance
|
||||
width int
|
||||
height int
|
||||
focus int
|
||||
err error
|
||||
useCloud bool
|
||||
useSelfHost bool
|
||||
cloudToken textinput.Model
|
||||
selfHostName textinput.Model
|
||||
selfHostFqdn textinput.Model
|
||||
selfHostToken textinput.Model
|
||||
result chan<- []coolTypes.Instance
|
||||
step int // Current step in the initialization process
|
||||
tick int // For rainbow effect
|
||||
keys initKeyMap
|
||||
help help.Model
|
||||
}
|
||||
|
||||
func newInitModel(result chan<- []coolTypes.Instance) initModel {
|
||||
cloudToken := textinput.New()
|
||||
cloudToken.Placeholder = "Enter your Coolify Cloud token"
|
||||
cloudToken.Prompt = "Cloud Token: "
|
||||
cloudToken.PromptStyle = tui.FocusedStyle
|
||||
cloudToken.TextStyle = tui.FocusedStyle
|
||||
cloudToken.Validate = tui.ValidateNotEmpty
|
||||
|
||||
selfHostName := textinput.New()
|
||||
selfHostName.Placeholder = "Enter name for self-hosted instance"
|
||||
selfHostName.Prompt = "Name: "
|
||||
selfHostName.PromptStyle = tui.FocusedStyle
|
||||
selfHostName.TextStyle = tui.FocusedStyle
|
||||
selfHostName.Validate = tui.ValidateNotEmpty
|
||||
|
||||
selfHostFqdn := textinput.New()
|
||||
selfHostFqdn.Placeholder = "Enter FQDN for self-hosted instance"
|
||||
selfHostFqdn.Prompt = "FQDN: "
|
||||
selfHostFqdn.PromptStyle = tui.FocusedStyle
|
||||
selfHostFqdn.TextStyle = tui.FocusedStyle
|
||||
selfHostFqdn.Validate = tui.ValidateFQDN
|
||||
|
||||
selfHostToken := textinput.New()
|
||||
selfHostToken.Placeholder = "Enter token for self-hosted instance"
|
||||
selfHostToken.Prompt = "Token: "
|
||||
selfHostToken.PromptStyle = tui.FocusedStyle
|
||||
selfHostToken.TextStyle = tui.FocusedStyle
|
||||
selfHostToken.Validate = tui.ValidateNotEmpty
|
||||
|
||||
return initModel{
|
||||
instances: make([]coolTypes.Instance, 0),
|
||||
focus: 0,
|
||||
result: result,
|
||||
step: 0,
|
||||
cloudToken: cloudToken,
|
||||
selfHostName: selfHostName,
|
||||
selfHostFqdn: selfHostFqdn,
|
||||
selfHostToken: selfHostToken,
|
||||
keys: initKeys,
|
||||
help: help.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m initModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Space):
|
||||
// Space toggles checkbox when on step 0 or 2
|
||||
switch m.step {
|
||||
case 0:
|
||||
m.useCloud = !m.useCloud
|
||||
return m, nil
|
||||
case 2:
|
||||
m.useSelfHost = !m.useSelfHost
|
||||
return m, nil
|
||||
}
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
switch m.step {
|
||||
case 0:
|
||||
// Enter handles progression
|
||||
if m.useCloud {
|
||||
m.step++
|
||||
m.focus = 1
|
||||
m.cloudToken.Focus()
|
||||
} else {
|
||||
m.step += 2
|
||||
m.focus = 2
|
||||
}
|
||||
case 1:
|
||||
if m.useCloud {
|
||||
// Check for validation errors
|
||||
if m.cloudToken.Err != nil {
|
||||
m.err = m.cloudToken.Err
|
||||
return m, nil
|
||||
}
|
||||
// Manual validation in case field hasn't been edited
|
||||
if m.cloudToken.Value() == "" {
|
||||
m.err = errors.New("token is required when using Coolify Cloud")
|
||||
return m, nil
|
||||
}
|
||||
m.step++
|
||||
m.focus = 2
|
||||
m.cloudToken.Blur()
|
||||
}
|
||||
case 2:
|
||||
// Enter handles progression
|
||||
if m.useSelfHost {
|
||||
m.step++
|
||||
m.focus = 3
|
||||
m.selfHostName.Focus()
|
||||
} else {
|
||||
// If self-hosted is false, build instances and quit
|
||||
if m.useCloud {
|
||||
m.instances = append(m.instances, coolTypes.Instance{
|
||||
Name: "cloud",
|
||||
Default: true,
|
||||
Fqdn: "https://app.coolify.io",
|
||||
Token: m.cloudToken.Value(),
|
||||
})
|
||||
}
|
||||
// Send instances back to command
|
||||
if m.result != nil {
|
||||
m.result <- m.instances
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
case 3:
|
||||
cloudToken := strings.TrimSpace(m.cloudToken.Value())
|
||||
if m.useSelfHost {
|
||||
// Check for validation errors
|
||||
if m.selfHostName.Err != nil || m.selfHostFqdn.Err != nil || m.selfHostToken.Err != nil {
|
||||
m.err = errors.New("please fix all field errors before submitting")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
selfHostName := strings.TrimSpace(m.selfHostName.Value())
|
||||
selfHostFqdn := strings.TrimSpace(m.selfHostFqdn.Value())
|
||||
selfHostToken := strings.TrimSpace(m.selfHostToken.Value())
|
||||
// Manual validation in case fields haven't been edited
|
||||
if selfHostName == "" {
|
||||
m.err = errors.New("name is required for self-hosted instance")
|
||||
return m, nil
|
||||
}
|
||||
if selfHostFqdn == "" {
|
||||
m.err = errors.New("FQDN is required for self-hosted instance")
|
||||
return m, nil
|
||||
}
|
||||
if selfHostToken == "" {
|
||||
m.err = errors.New("token is required for self-hosted instance")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Build instances array
|
||||
if m.useCloud {
|
||||
m.instances = append(m.instances, coolTypes.Instance{
|
||||
Name: "cloud",
|
||||
Default: true,
|
||||
Fqdn: "https://app.coolify.io",
|
||||
Token: cloudToken,
|
||||
})
|
||||
}
|
||||
m.instances = append(m.instances, coolTypes.Instance{
|
||||
Name: selfHostName,
|
||||
Default: !m.useCloud,
|
||||
Fqdn: selfHostFqdn,
|
||||
Token: selfHostToken,
|
||||
})
|
||||
// Send instances back to command
|
||||
if m.result != nil {
|
||||
m.result <- m.instances
|
||||
}
|
||||
return m, tea.Quit
|
||||
} else {
|
||||
// If self-hosted is false, build instances and quit
|
||||
if m.useCloud {
|
||||
m.instances = append(m.instances, coolTypes.Instance{
|
||||
Name: "cloud",
|
||||
Default: true,
|
||||
Fqdn: "https://app.coolify.io",
|
||||
Token: cloudToken,
|
||||
})
|
||||
}
|
||||
// Send instances back to command
|
||||
if m.result != nil {
|
||||
m.result <- m.instances
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
// Only allow up/down navigation when multiple items are visible
|
||||
if m.step == 3 && m.useSelfHost {
|
||||
m.focus--
|
||||
if m.focus < 3 {
|
||||
m.focus = 5
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab):
|
||||
// Only allow up/down navigation when multiple items are visible
|
||||
if m.step == 3 && m.useSelfHost {
|
||||
m.focus++
|
||||
if m.focus > 5 {
|
||||
m.focus = 3
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.help.Width = msg.Width
|
||||
}
|
||||
|
||||
// Handle text input updates
|
||||
if m.step == 1 && m.focus == 1 {
|
||||
m.cloudToken, cmd = m.cloudToken.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
} else if m.step == 3 {
|
||||
switch m.focus {
|
||||
case 3:
|
||||
m.selfHostName, cmd = m.selfHostName.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
case 4:
|
||||
m.selfHostFqdn, cmd = m.selfHostFqdn.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
case 5:
|
||||
m.selfHostToken, cmd = m.selfHostToken.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *initModel) updateFocus() {
|
||||
// Blur all inputs
|
||||
m.cloudToken.Blur()
|
||||
m.selfHostName.Blur()
|
||||
m.selfHostFqdn.Blur()
|
||||
m.selfHostToken.Blur()
|
||||
|
||||
// Focus the selected input
|
||||
switch m.focus {
|
||||
case 1:
|
||||
m.cloudToken.Focus()
|
||||
case 3:
|
||||
m.selfHostName.Focus()
|
||||
case 4:
|
||||
m.selfHostFqdn.Focus()
|
||||
case 5:
|
||||
m.selfHostToken.Focus()
|
||||
}
|
||||
}
|
||||
|
||||
func (m initModel) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
var s strings.Builder
|
||||
|
||||
// Title
|
||||
s.WriteString("Initialize Coolify CLI\n\n")
|
||||
|
||||
// Step 1: Cloud question
|
||||
if m.step == 0 {
|
||||
cloudStyle := tui.BlurredStyle
|
||||
if m.focus == 0 {
|
||||
cloudStyle = tui.FocusedStyle
|
||||
}
|
||||
s.WriteString(cloudStyle.Render("Do you use "))
|
||||
s.WriteString(goldStyle.Render("Coolify Cloud?"))
|
||||
s.WriteString(" ")
|
||||
if m.useCloud {
|
||||
s.WriteString(checked)
|
||||
} else {
|
||||
s.WriteString(unchecked)
|
||||
}
|
||||
s.WriteString("\n")
|
||||
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
|
||||
}
|
||||
|
||||
// Step 2: Cloud token input
|
||||
if m.step == 1 && m.useCloud {
|
||||
s.WriteString(m.cloudToken.View())
|
||||
if m.cloudToken.Err != nil {
|
||||
// Display validation error next to input
|
||||
s.WriteString(" ")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.cloudToken.Err.Error()))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
// Step 3: Self-hosted question
|
||||
if m.step == 2 {
|
||||
selfHostStyle := tui.BlurredStyle
|
||||
if m.focus == 2 {
|
||||
selfHostStyle = tui.FocusedStyle
|
||||
}
|
||||
s.WriteString(selfHostStyle.Render("Add self-hosted instance"))
|
||||
s.WriteString(" ")
|
||||
if m.useSelfHost {
|
||||
s.WriteString(checked)
|
||||
} else {
|
||||
s.WriteString(unchecked)
|
||||
}
|
||||
s.WriteString("\n")
|
||||
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
|
||||
}
|
||||
|
||||
// Step 4: Self-hosted inputs
|
||||
if m.step == 3 && m.useSelfHost {
|
||||
// Name input
|
||||
s.WriteString(m.selfHostName.View())
|
||||
if m.selfHostName.Err != nil {
|
||||
// Display validation error next to input
|
||||
s.WriteString(" ")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.selfHostName.Err.Error()))
|
||||
}
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// FQDN input
|
||||
s.WriteString(m.selfHostFqdn.View())
|
||||
if m.selfHostFqdn.Err != nil {
|
||||
// Display validation error next to input
|
||||
s.WriteString(" ")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.selfHostFqdn.Err.Error()))
|
||||
}
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// Token input
|
||||
s.WriteString(m.selfHostToken.View())
|
||||
if m.selfHostToken.Err != nil {
|
||||
// Display validation error next to input
|
||||
s.WriteString(" ")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.selfHostToken.Err.Error()))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
// Help view
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(m.help.View(m.keys))
|
||||
|
||||
// Error message
|
||||
if m.err != nil {
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package cliinstances
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (c *cliInstances) newAddCommand() *cobra.Command {
|
||||
force := false
|
||||
isNewDefault := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [name] [fqdn] [token]",
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s instances add MyInstance https://my.instance.tld 1234
|
||||
%[1]s instances add AnotherInstance https://another.instance.tld 5678 --default
|
||||
%[1]s instances add MyInstance https://my.instance.tld 91011 --force
|
||||
%[1]s instances add # Interactive mode
|
||||
`),
|
||||
Short: "Add a new instance",
|
||||
Long: `
|
||||
Add a new instance to the CLI configuration file.
|
||||
If no arguments are provided, an interactive form will be shown.
|
||||
`,
|
||||
Aliases: []string{"create"},
|
||||
SilenceUsage: true,
|
||||
Args: cobra.RangeArgs(0, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return c.runInteractiveMode(cmd, force, isNewDefault)
|
||||
} else if len(args) != 3 {
|
||||
return errors.New("command requires either 0 arguments (interactive mode) or exactly 3 arguments (name, fqdn, token)")
|
||||
}
|
||||
return c.runNonInteractiveMode(args, force, isNewDefault)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&force, "force", "f", false, "Force overwrite existing instance with the same name")
|
||||
flags.BoolVarP(&isNewDefault, "default", "d", false, "Set this instance as the default instance")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c *cliInstances) runInteractiveMode(cmd *cobra.Command, force, isDefault bool) error {
|
||||
result := make(chan coolTypes.Instance)
|
||||
p := tea.NewProgram(newAddModel(result, force, isDefault))
|
||||
|
||||
// Create a done channel to signal when the program is finished
|
||||
done := make(chan struct{})
|
||||
var programErr error
|
||||
|
||||
// Run the program in a goroutine
|
||||
go func() {
|
||||
_, programErr = p.Run()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait for either the instance or context cancellation
|
||||
var instance coolTypes.Instance
|
||||
select {
|
||||
case instance = <-result:
|
||||
case <-cmd.Context().Done():
|
||||
return fmt.Errorf("operation cancelled")
|
||||
case <-done:
|
||||
if programErr != nil {
|
||||
return fmt.Errorf("program error: %v", programErr)
|
||||
}
|
||||
return fmt.Errorf("program exited without saving instance")
|
||||
}
|
||||
|
||||
// Check for existing instance with same name
|
||||
for i, existing := range c.instances {
|
||||
if existing.Name == instance.Name {
|
||||
if !force {
|
||||
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
|
||||
}
|
||||
c.instances = slices.Delete(c.instances, i, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isDefault {
|
||||
for i := range c.instances {
|
||||
c.instances[i].Default = false
|
||||
}
|
||||
}
|
||||
|
||||
c.instances = append(c.instances, instance)
|
||||
viper.Set("instances", c.instances)
|
||||
return c.coolify().Save()
|
||||
}
|
||||
|
||||
func (c *cliInstances) runNonInteractiveMode(args []string, force, isNewDefault bool) error {
|
||||
// Check for existing instance with same name
|
||||
for i, instance := range c.instances {
|
||||
if instance.Name == args[0] {
|
||||
if !force {
|
||||
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
|
||||
}
|
||||
c.instances = slices.Delete(c.instances, i, i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
newInstance := coolTypes.Instance{
|
||||
Name: args[0],
|
||||
Fqdn: args[1],
|
||||
Token: args[2],
|
||||
Default: isNewDefault,
|
||||
}
|
||||
|
||||
if isNewDefault {
|
||||
for i := range c.instances {
|
||||
c.instances[i].Default = false
|
||||
}
|
||||
}
|
||||
|
||||
c.instances = append(c.instances, newInstance)
|
||||
viper.Set("instances", c.instances)
|
||||
return c.coolify().Save()
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package cliinstances
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
)
|
||||
|
||||
// addKeyMap defines keybindings for the add instance form
|
||||
type addKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Tab key.Binding
|
||||
Enter key.Binding
|
||||
Paste key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp returns keybindings to be shown in the mini help view
|
||||
func (k addKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Help, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp returns keybindings for the expanded help view
|
||||
func (k addKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Tab}, // first column
|
||||
{k.Enter, k.Paste, k.Help}, // second column
|
||||
{k.Quit}, // third column
|
||||
}
|
||||
}
|
||||
|
||||
var addKeys = addKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "move down"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab", "shift+tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit/select"),
|
||||
),
|
||||
Paste: key.NewBinding(
|
||||
key.WithKeys("ctrl+v"),
|
||||
key.WithHelp("ctrl+v", "paste"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+c"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
type addModel struct {
|
||||
inputs []textinput.Model
|
||||
focus int
|
||||
err error
|
||||
instance coolTypes.Instance
|
||||
width int
|
||||
height int
|
||||
result chan<- coolTypes.Instance
|
||||
force bool
|
||||
isDefault bool
|
||||
keys addKeyMap
|
||||
help help.Model
|
||||
}
|
||||
|
||||
func newAddModel(result chan<- coolTypes.Instance, force, isDefault bool) addModel {
|
||||
// Create text inputs
|
||||
inputs := make([]textinput.Model, 3)
|
||||
labels := []string{"Name", "FQDN", "Token"}
|
||||
|
||||
for i, label := range labels {
|
||||
input := textinput.New()
|
||||
input.Placeholder = fmt.Sprintf("Enter instance %s", label)
|
||||
input.Prompt = fmt.Sprintf("%s: ", label)
|
||||
input.PromptStyle = tui.FocusedStyle
|
||||
input.TextStyle = tui.FocusedStyle
|
||||
|
||||
// Set up validation for each input type
|
||||
switch label {
|
||||
case "Name":
|
||||
input.Validate = tui.ValidateNotEmpty
|
||||
case "FQDN":
|
||||
input.Validate = tui.ValidateFQDN
|
||||
case "Token":
|
||||
input.Validate = tui.ValidateNotEmpty
|
||||
}
|
||||
|
||||
// Focus first input by default
|
||||
if i == 0 {
|
||||
input.Focus()
|
||||
}
|
||||
|
||||
inputs[i] = input
|
||||
}
|
||||
|
||||
return addModel{
|
||||
inputs: inputs,
|
||||
focus: 0,
|
||||
result: result,
|
||||
force: force,
|
||||
isDefault: isDefault,
|
||||
keys: addKeys,
|
||||
help: help.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m addModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
if m.focus == len(m.inputs) {
|
||||
// Submit - first check if any field has validation errors
|
||||
for _, input := range m.inputs {
|
||||
if input.Err != nil {
|
||||
// Don't proceed if any field has validation errors
|
||||
m.err = errors.New("please fix all field errors before submitting")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Also validate in case fields haven't been edited
|
||||
if err := m.validateOnSubmit(); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.instance = coolTypes.Instance{
|
||||
Name: strings.TrimSpace(m.inputs[0].Value()),
|
||||
Fqdn: strings.TrimSpace(m.inputs[1].Value()),
|
||||
Token: strings.TrimSpace(m.inputs[2].Value()),
|
||||
Default: m.isDefault,
|
||||
}
|
||||
// Return a command to send the instance
|
||||
return m, func() tea.Msg {
|
||||
if m.result != nil {
|
||||
m.result <- m.instance
|
||||
}
|
||||
return tea.Quit()
|
||||
}
|
||||
} else if m.focus == len(m.inputs)+1 {
|
||||
// Cancel
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Move to next input
|
||||
m.focus++
|
||||
m.updateFocus()
|
||||
case key.Matches(msg, m.keys.Tab):
|
||||
if msg.String() == "tab" {
|
||||
m.focus++
|
||||
} else {
|
||||
m.focus--
|
||||
}
|
||||
|
||||
// Wrap around
|
||||
if m.focus > len(m.inputs)+1 {
|
||||
m.focus = 0
|
||||
} else if m.focus < 0 {
|
||||
m.focus = len(m.inputs) + 1
|
||||
}
|
||||
|
||||
m.updateFocus()
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
m.focus--
|
||||
if m.focus < 0 {
|
||||
m.focus = len(m.inputs) + 1
|
||||
}
|
||||
m.updateFocus()
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
m.focus++
|
||||
if m.focus > len(m.inputs)+1 {
|
||||
m.focus = 0
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.help.Width = msg.Width
|
||||
}
|
||||
|
||||
// Handle text input updates
|
||||
if m.focus < len(m.inputs) {
|
||||
var cmd tea.Cmd
|
||||
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *addModel) updateFocus() {
|
||||
// Blur all inputs
|
||||
for i := range m.inputs {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
|
||||
// Focus current input if it's a text input
|
||||
if m.focus < len(m.inputs) {
|
||||
m.inputs[m.focus].Focus()
|
||||
}
|
||||
}
|
||||
|
||||
// validateOnSubmit handles validation for fields that haven't been edited
|
||||
func (m addModel) validateOnSubmit() error {
|
||||
// Trigger validation for all fields
|
||||
for i, input := range m.inputs {
|
||||
// If the field hasn't been edited and is empty, it hasn't triggered validation yet
|
||||
switch i {
|
||||
case 0:
|
||||
return tui.ValidateNotEmpty(input.Value())
|
||||
case 1:
|
||||
return tui.ValidateFQDN(input.Value())
|
||||
case 2:
|
||||
return tui.ValidateNotEmpty(input.Value())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m addModel) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading..."
|
||||
}
|
||||
|
||||
var s strings.Builder
|
||||
|
||||
// Title
|
||||
s.WriteString("Add New Instance\n\n")
|
||||
|
||||
// Input fields with validation errors
|
||||
for _, input := range m.inputs {
|
||||
s.WriteString(input.View())
|
||||
if input.Err != nil {
|
||||
// Display the validation error next to the input
|
||||
s.WriteString(" ")
|
||||
s.WriteString(tui.ErrorStyle.Render(input.Err.Error()))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
// Submit and Cancel buttons
|
||||
submitStyle := tui.BlurredStyle
|
||||
if m.focus == len(m.inputs) {
|
||||
submitStyle = tui.FocusedStyle
|
||||
}
|
||||
s.WriteString(submitStyle.Render("Submit"))
|
||||
s.WriteString(" ")
|
||||
|
||||
cancelStyle := tui.BlurredStyle
|
||||
if m.focus == len(m.inputs)+1 {
|
||||
cancelStyle = tui.FocusedStyle
|
||||
}
|
||||
s.WriteString(cancelStyle.Render("Cancel"))
|
||||
|
||||
// Help view at the bottom
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(m.help.View(m.keys))
|
||||
|
||||
// General form error message (if any)
|
||||
if m.err != nil {
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package cliinstances
|
||||
|
||||
import (
|
||||
cliinstancesset "github.com/coollabsio/cli-coolify/cmd/cliinstances/set"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type cliInstances struct {
|
||||
coolify runtime.Getter
|
||||
instances []coolTypes.Instance
|
||||
}
|
||||
|
||||
func (c *cliInstances) runtime() *runtime.Coolify {
|
||||
return c.coolify()
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliInstances {
|
||||
return &cliInstances{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cliInstances) NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "Manage CLI instances",
|
||||
Aliases: []string{"instance"},
|
||||
Long: `
|
||||
Manage CLI instances by adding, removing or setting options for the instance.
|
||||
`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if instances := viper.Get("instances"); instances != nil {
|
||||
return viper.UnmarshalKey("instances", &c.instances)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(c.newAddCommand())
|
||||
cmd.AddCommand(c.newRemoveCommand())
|
||||
cmd.AddCommand(c.newListCommand())
|
||||
cmd.AddCommand(cliinstancesset.New(c.runtime).NewCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package cliinstances
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/emoji"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// wrappedInstance implements the FilterableItem interface
|
||||
type wrappedInstance struct {
|
||||
instance coolTypes.Instance
|
||||
}
|
||||
|
||||
func (w wrappedInstance) GetFilterValue() string {
|
||||
return w.instance.Name
|
||||
}
|
||||
|
||||
type filterableListModel struct {
|
||||
filterableTable *tui.FilterableTable
|
||||
}
|
||||
|
||||
func (c *cliInstances) handleDelete(item tui.FilterableItem) error {
|
||||
instance := item.(wrappedInstance).instance
|
||||
|
||||
// Don't allow deleting default instance without force flag
|
||||
if instance.Default {
|
||||
return fmt.Errorf("cannot delete default instance. Use 'instances remove %s --force' instead", instance.Name)
|
||||
}
|
||||
|
||||
// Find and remove the instance from the slice
|
||||
for i, existing := range c.instances {
|
||||
if existing.Name == instance.Name {
|
||||
c.instances = append(c.instances[:i], c.instances[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Update viper and save
|
||||
viper.Set("instances", c.instances)
|
||||
return c.coolify().Save()
|
||||
}
|
||||
|
||||
func newFilterableListModel(instances []coolTypes.Instance, sensitive bool, initialFilter string, deleteHandler func(tui.FilterableItem) error) *filterableListModel {
|
||||
columns := []table.Column{
|
||||
{Title: "Name", Width: 30},
|
||||
{Title: "URL", Width: 40},
|
||||
{Title: "Default", Width: 8},
|
||||
}
|
||||
|
||||
// Convert instances to FilterableItems
|
||||
items := make([]tui.FilterableItem, len(instances))
|
||||
for i, instance := range instances {
|
||||
items[i] = wrappedInstance{instance: instance}
|
||||
}
|
||||
|
||||
// Create row builder function
|
||||
rowBuilder := func(item tui.FilterableItem) table.Row {
|
||||
instance := item.(wrappedInstance).instance
|
||||
e := emoji.CrossMark
|
||||
if instance.Default {
|
||||
e = emoji.CheckMarkButton
|
||||
}
|
||||
|
||||
return table.Row{
|
||||
instance.Name,
|
||||
instance.Fqdn,
|
||||
e,
|
||||
}
|
||||
}
|
||||
|
||||
// Create detail view builder function
|
||||
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
|
||||
instance := item.(wrappedInstance).instance
|
||||
var s strings.Builder
|
||||
|
||||
addSection := func(title, value string) {
|
||||
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
|
||||
s.WriteString(value + "\n\n")
|
||||
}
|
||||
|
||||
addSection("Name", instance.Name)
|
||||
addSection("URL", instance.Fqdn)
|
||||
if sensitive {
|
||||
addSection("Token", instance.Token)
|
||||
} else {
|
||||
addSection("Token", "********")
|
||||
}
|
||||
addSection("Default", fmt.Sprintf("%v", instance.Default))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
ft := tui.NewTableFilter(items, columns, rowBuilder).
|
||||
WithInitialFilter(initialFilter).
|
||||
WithDetailView(detailBuilder).
|
||||
WithDetailHeader("Instance Details").
|
||||
WithDeleteHandler(deleteHandler)
|
||||
|
||||
return &filterableListModel{
|
||||
filterableTable: ft,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *filterableListModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.filterableTable.Update(msg)
|
||||
}
|
||||
|
||||
func (m *filterableListModel) View() string {
|
||||
return m.filterableTable.View()
|
||||
}
|
||||
|
||||
func (c *cliInstances) newListCommand() *cobra.Command {
|
||||
sensitive := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [name]",
|
||||
Short: "List all instances",
|
||||
Long: `
|
||||
List all instances from the CLI configuration file.
|
||||
If a name is provided, only instances matching that name will be shown.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
initialFilter := ""
|
||||
if len(args) > 0 {
|
||||
initialFilter = args[0]
|
||||
}
|
||||
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get format: %v", err)
|
||||
}
|
||||
// If format is json, output JSON and exit
|
||||
if format == "json" {
|
||||
// Filter instances for JSON output
|
||||
filteredInstances := filterInstances(c.instances, initialFilter)
|
||||
|
||||
// If not sensitive, redact tokens
|
||||
if !sensitive {
|
||||
filteredInstances = redactTokens(filteredInstances)
|
||||
}
|
||||
|
||||
// Encode directly to JSON using the struct's annotations
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(filteredInstances)
|
||||
}
|
||||
|
||||
// Run interactive UI
|
||||
p := tea.NewProgram(newFilterableListModel(c.instances, sensitive, initialFilter, c.handleDelete))
|
||||
_, err = p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("program error: %v", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&sensitive, "sensitive", "s", false, "Show sensitive information such as tokens")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// filterInstances filters instances based on a name filter
|
||||
func filterInstances(instances []coolTypes.Instance, filter string) []coolTypes.Instance {
|
||||
if filter == "" {
|
||||
return instances
|
||||
}
|
||||
|
||||
filtered := make([]coolTypes.Instance, 0)
|
||||
for _, instance := range instances {
|
||||
if strings.Contains(strings.ToLower(instance.Name), strings.ToLower(filter)) {
|
||||
filtered = append(filtered, instance)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// redactTokens creates a copy of instances with redacted tokens
|
||||
func redactTokens(instances []coolTypes.Instance) []coolTypes.Instance {
|
||||
redacted := make([]coolTypes.Instance, len(instances))
|
||||
for i, instance := range instances {
|
||||
// Create a copy to avoid modifying original
|
||||
redacted[i] = instance
|
||||
if instance.Token != "" {
|
||||
redacted[i].Token = "********"
|
||||
}
|
||||
}
|
||||
return redacted
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cliinstances
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (c *cliInstances) newRemoveCommand() *cobra.Command {
|
||||
force := false
|
||||
indexToRemove := -1
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove [name]",
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s instances remove MyInstance
|
||||
%[1]s instances remove localhost --force
|
||||
`),
|
||||
Short: "remove a instance",
|
||||
Long: `
|
||||
remove a instance from CLI configuration file.
|
||||
`,
|
||||
Aliases: []string{"delete"},
|
||||
SilenceUsage: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
for i, instance := range c.instances {
|
||||
if instance.Name == args[0] {
|
||||
if !force && instance.Default {
|
||||
return errors.New("instance is set as default. Please set another instance as default before removing this instance or provide the force flag")
|
||||
}
|
||||
indexToRemove = i
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("instance name is not found in the configuration file")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c.instances = slices.Delete(c.instances, indexToRemove, indexToRemove+1)
|
||||
viper.Set("instances", c.instances)
|
||||
return c.coolify().Save()
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&force, "force", "f", false, "Force remove instance if set as default")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cliinstancesset
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (c *cliInstancesSet) newSetDefaultCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "default [name]",
|
||||
Short: "set a instance as default",
|
||||
Long: `
|
||||
set a instance as default from CLI configuration file.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for i := range c.instances {
|
||||
c.instances[i].Default = c.instances[i].Name == args[0]
|
||||
}
|
||||
viper.Set("instances", c.instances)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package cliinstancesset
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type cliInstancesSet struct {
|
||||
coolify runtime.Getter
|
||||
instances []coolTypes.Instance
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliInstancesSet {
|
||||
return &cliInstancesSet{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
// Set command modifies property on a instance. Pre and Post run functions validate all children commands and save the configuration file after the child commands sets a property.
|
||||
// TLDR; children commands dont need to save the configuration file or do any validation "if instances exists".
|
||||
func (c *cliInstancesSet) NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "set [command] [args]",
|
||||
Short: "set a property on a instance",
|
||||
Long: `
|
||||
set a property on a instance from CLI configuration file.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if instances := viper.Get("instances"); instances != nil {
|
||||
err := viper.UnmarshalKey("instances", &c.instances)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Validate all set commands have instance name as the first argument and is found in the configuration file.
|
||||
for _, instance := range c.instances {
|
||||
if instance.Name == args[0] {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("instance name is not found in the configuration file")
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Save the configuration file after setting the property.
|
||||
return c.coolify().Save()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(c.newSetDefaultCommand())
|
||||
cmd.AddCommand(c.newSetTokenCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package cliinstancesset
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
func (c *cliInstancesSet) newSetTokenCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "token [name] [token]",
|
||||
Short: "set a instance token",
|
||||
Long: `
|
||||
set a instance token from CLI configuration file.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for i := range c.instances {
|
||||
if c.instances[i].Name == args[0] {
|
||||
c.instances[i].Token = args[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
viper.Set("instances", c.instances)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package cliprivatekeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// addKeyMap defines keybindings for the add private key form
|
||||
type addKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Tab key.Binding
|
||||
Enter key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp returns keybindings to be shown in the mini help view
|
||||
func (k addKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Help, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp returns keybindings for the expanded help view
|
||||
func (k addKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Tab}, // first column
|
||||
{k.Enter, k.Help}, // second column
|
||||
{k.Quit}, // third column
|
||||
}
|
||||
}
|
||||
|
||||
var addKeys = addKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "move down"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab", "shift+tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit/select"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+c"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
// addKeyModel is the Bubble Tea model for the interactive add key form
|
||||
type addKeyModel struct {
|
||||
nameInput textinput.Model
|
||||
keyInput textinput.Model
|
||||
focusIndex int
|
||||
done bool
|
||||
err error
|
||||
coolify *runtime.Coolify
|
||||
keys addKeyMap
|
||||
help help.Model
|
||||
}
|
||||
|
||||
func initialAddKeyModel(coolify *runtime.Coolify) addKeyModel {
|
||||
m := addKeyModel{
|
||||
coolify: coolify,
|
||||
keys: addKeys,
|
||||
help: help.New(),
|
||||
}
|
||||
|
||||
// Setup name input
|
||||
m.nameInput = tui.NewFocusedInput("My SSH Key", "› ")
|
||||
m.nameInput.CharLimit = 50
|
||||
m.nameInput.Width = 40
|
||||
|
||||
// Setup key input (multi-line)
|
||||
m.keyInput = tui.NewBlurredInput("SSH private key or path to key file", "› ")
|
||||
m.keyInput.CharLimit = 4096
|
||||
m.keyInput.Width = 60
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m addKeyModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m addKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
if key.Matches(msg, m.keys.Quit) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Help) {
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Enter) {
|
||||
// Submit on enter when key input is focused
|
||||
if m.focusIndex == 1 {
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Otherwise move to next input
|
||||
m.focusIndex++
|
||||
if m.focusIndex > 1 {
|
||||
m.focusIndex = 0
|
||||
}
|
||||
return m, m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Tab) {
|
||||
// Cycle focus between inputs
|
||||
if msg.String() == "shift+tab" {
|
||||
m.focusIndex--
|
||||
} else {
|
||||
m.focusIndex++
|
||||
}
|
||||
|
||||
if m.focusIndex > 1 {
|
||||
m.focusIndex = 0
|
||||
} else if m.focusIndex < 0 {
|
||||
m.focusIndex = 1
|
||||
}
|
||||
return m, m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Up) {
|
||||
m.focusIndex--
|
||||
if m.focusIndex < 0 {
|
||||
m.focusIndex = 1
|
||||
}
|
||||
return m, m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Down) {
|
||||
m.focusIndex++
|
||||
if m.focusIndex > 1 {
|
||||
m.focusIndex = 0
|
||||
}
|
||||
return m, m.updateFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle character input for the active input
|
||||
if m.focusIndex == 0 {
|
||||
var cmd tea.Cmd
|
||||
m.nameInput, cmd = m.nameInput.Update(msg)
|
||||
return m, cmd
|
||||
} else {
|
||||
var cmd tea.Cmd
|
||||
m.keyInput, cmd = m.keyInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
func (m addKeyModel) updateFocus() tea.Cmd {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
if m.focusIndex == 0 {
|
||||
m.nameInput.PromptStyle = tui.FocusedStyle
|
||||
m.nameInput.TextStyle = tui.FocusedStyle
|
||||
m.keyInput.PromptStyle = tui.BlurredStyle
|
||||
m.keyInput.TextStyle = tui.BlurredStyle
|
||||
cmds = append(cmds, m.nameInput.Focus())
|
||||
m.keyInput.Blur()
|
||||
} else {
|
||||
m.keyInput.PromptStyle = tui.FocusedStyle
|
||||
m.keyInput.TextStyle = tui.FocusedStyle
|
||||
m.nameInput.PromptStyle = tui.BlurredStyle
|
||||
m.nameInput.TextStyle = tui.BlurredStyle
|
||||
cmds = append(cmds, m.keyInput.Focus())
|
||||
m.nameInput.Blur()
|
||||
}
|
||||
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m addKeyModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Title with Coolify branding
|
||||
title := tui.FocusedStyle.Bold(true).Render("Add New SSH Private Key")
|
||||
b.WriteString(title + "\n\n")
|
||||
|
||||
// Render inputs with labels
|
||||
labelStyle := tui.BlurredStyle.Width(12)
|
||||
|
||||
b.WriteString(labelStyle.Render("Name:") + " " + m.nameInput.View() + "\n\n")
|
||||
b.WriteString(labelStyle.Render("Private Key:") + " " + m.keyInput.View() + "\n\n")
|
||||
|
||||
// Add help view
|
||||
if m.help.ShowAll {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.help.View(m.keys))
|
||||
} else {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func generateRSAKeyPair() (privateBytes, publicBytes []byte, err error) {
|
||||
// Generate RSA key pair
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate RSA key pair: %w", err)
|
||||
}
|
||||
|
||||
// Convert private key to PEM format
|
||||
privateKeyPEM := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
|
||||
}
|
||||
privateBytes = pem.EncodeToMemory(privateKeyPEM)
|
||||
|
||||
// Generate public key
|
||||
publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
|
||||
}
|
||||
publicBytes = ssh.MarshalAuthorizedKey(publicKey)
|
||||
|
||||
return privateBytes, publicBytes, nil
|
||||
}
|
||||
|
||||
func generateEd25519KeyPair() (privateBytes, publicBytes []byte, err error) {
|
||||
// Generate Ed25519 key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate Ed25519 key pair: %w", err)
|
||||
}
|
||||
privateKeyPem, err := ssh.MarshalPrivateKey(privateKey, "")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
privateBytes = pem.EncodeToMemory(privateKeyPem)
|
||||
|
||||
// Generate public key
|
||||
sshPublicKey, err := ssh.NewPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
|
||||
}
|
||||
publicBytes = ssh.MarshalAuthorizedKey(sshPublicKey)
|
||||
|
||||
return privateBytes, publicBytes, nil
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) generateKeyPair(name, outputDir, alorithim string, force bool) (string, error) {
|
||||
var privateKey, publicKey []byte
|
||||
var err error
|
||||
switch alorithim {
|
||||
case "rsa":
|
||||
privateKey, publicKey, err = generateRSAKeyPair()
|
||||
case "ed25519":
|
||||
privateKey, publicKey, err = generateEd25519KeyPair()
|
||||
default:
|
||||
return "", fmt.Errorf("invalid alorithim: %s", alorithim)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if outputDir != "" {
|
||||
if err := os.MkdirAll(outputDir, 0o700); err != nil {
|
||||
return "", fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Write private key file
|
||||
privateKeyPath := filepath.Join(outputDir, name)
|
||||
if !force {
|
||||
if _, err := os.Stat(privateKeyPath); err == nil {
|
||||
return "", fmt.Errorf("private key file already exists: %s", privateKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(privateKeyPath, privateKey, 0o600); err != nil {
|
||||
return "", fmt.Errorf("failed to write private key file: %w", err)
|
||||
}
|
||||
|
||||
// Write public key file
|
||||
publicKeyPath := privateKeyPath + ".pub"
|
||||
if err := os.WriteFile(publicKeyPath, publicKey, 0o644); err != nil {
|
||||
return "", fmt.Errorf("failed to write public key file: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Generated SSH key pair:\n")
|
||||
fmt.Printf(" Private key: %s\n", privateKeyPath)
|
||||
fmt.Printf(" Public key: %s\n", publicKeyPath)
|
||||
}
|
||||
return string(privateKey), nil
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) newAddCommand() *cobra.Command {
|
||||
var generateKeyPair bool
|
||||
var outPutDirectory string
|
||||
var algorithm string
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [name] [private_key_or_file]",
|
||||
Short: "Add a new private key",
|
||||
Long: `Add a new SSH private key to your Coolify instance.
|
||||
The key can be provided directly as a string or as a path to a file.
|
||||
Use --generate to create a new SSH key pair.
|
||||
|
||||
If no arguments are provided, an interactive form will be used.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s private-keys add "My Key" /path/to/id_rsa
|
||||
%[1]s private-keys add "My Key" "-----BEGIN RSA PRIVATE KEY-----..."
|
||||
%[1]s private-keys add "My Key" --generate # Generate key pair
|
||||
%[1]s private-keys add # Interactive mode
|
||||
`),
|
||||
SilenceUsage: true,
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if generateKeyPair {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("when using --generate, provide only the key name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return cobra.RangeArgs(0, 2)(cmd, args)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Handle key generation
|
||||
if generateKeyPair {
|
||||
name := args[0]
|
||||
privateKey, err := c.generateKeyPair(name, outPutDirectory, algorithm, force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.addPrivateKey(cmd.Context(), name, privateKey)
|
||||
}
|
||||
|
||||
// Interactive mode when no arguments are provided
|
||||
if len(args) == 0 {
|
||||
model := initialAddKeyModel(c.coolify())
|
||||
p := tea.NewProgram(model)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running interactive mode: %w", err)
|
||||
}
|
||||
|
||||
// Process the final model after user submission
|
||||
finalState := finalModel.(addKeyModel)
|
||||
if !finalState.done {
|
||||
return fmt.Errorf("operation canceled")
|
||||
}
|
||||
|
||||
name := finalState.nameInput.Value()
|
||||
privateKeyInput := finalState.keyInput.Value()
|
||||
|
||||
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
|
||||
}
|
||||
|
||||
// CLI mode with arguments
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("requires both NAME and PRIVATE_KEY_OR_FILE arguments")
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
privateKeyInput := args[1]
|
||||
|
||||
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.SortFlags = false
|
||||
flags.BoolVarP(&generateKeyPair, "generate", "g", false, "generate a new key pair")
|
||||
flags.StringVarP(&algorithm, "algorithm", "a", "rsa", "algorithm to use for the key pair")
|
||||
flags.StringVarP(&outPutDirectory, "output", "o", "", "optional output directory for the key pair")
|
||||
flags.BoolVarP(&force, "force", "f", false, "force the generation of the key pair if the name exists on the file system within the output directory")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// addPrivateKey adds a private key to the Coolify instance
|
||||
func (c *cliPrivateKeys) addPrivateKey(ctx context.Context, name, privateKeyInput string) error {
|
||||
// Check if input is a file path
|
||||
var privateKey string
|
||||
if _, err := os.Stat(privateKeyInput); err == nil {
|
||||
keyBytes, err := os.ReadFile(privateKeyInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading private key file: %w", err)
|
||||
}
|
||||
privateKey = string(keyBytes)
|
||||
} else {
|
||||
privateKey = privateKeyInput
|
||||
}
|
||||
|
||||
req, err := c.coolify().Client.CreatePrivateKey(ctx, openapi.CreatePrivateKeyJSONRequestBody{
|
||||
Name: &name,
|
||||
PrivateKey: privateKey,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseCreatePrivateKeyResponse(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if parsedResponse.StatusCode() != http.StatusCreated {
|
||||
return fmt.Errorf("failed to add private key: %s", string(parsedResponse.Body))
|
||||
}
|
||||
|
||||
fmt.Printf("Private key '%s' added successfully as UUID: %s\n", name, *parsedResponse.JSON201.Uuid)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package cliprivatekeys
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func buildView(item openapi.PrivateKey, sensitive bool) string {
|
||||
var s strings.Builder
|
||||
addSection := func(title string, value interface{}) {
|
||||
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
|
||||
if value != nil {
|
||||
switch v := value.(type) {
|
||||
case *string:
|
||||
if v != nil {
|
||||
s.WriteString(*v + "\n\n")
|
||||
}
|
||||
case *bool:
|
||||
if v != nil {
|
||||
s.WriteString(fmt.Sprintf("%v\n\n", *v))
|
||||
}
|
||||
case *int:
|
||||
if v != nil {
|
||||
s.WriteString(fmt.Sprintf("%d\n\n", *v))
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
s.WriteString("N/A\n\n")
|
||||
}
|
||||
}
|
||||
addSection("UUID", item.Uuid)
|
||||
addSection("Name", item.Name)
|
||||
addSection("Description", item.Description)
|
||||
addSection("Fingerprint", item.Fingerprint)
|
||||
|
||||
if sensitive {
|
||||
addSection("Private Key", item.PrivateKey)
|
||||
addSection("Public Key", item.PublicKey)
|
||||
} else {
|
||||
addSection("Private Key", &coolTypes.Redacted)
|
||||
addSection("Public Key", &coolTypes.Redacted)
|
||||
}
|
||||
|
||||
addSection("Git Related", item.IsGitRelated)
|
||||
addSection("Team ID", item.TeamId)
|
||||
addSection("Created At", item.CreatedAt)
|
||||
addSection("Updated At", item.UpdatedAt)
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
PageUp key.Binding
|
||||
PageDown key.Binding
|
||||
Quit key.Binding
|
||||
ShowSensitive key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "move down"),
|
||||
),
|
||||
PageUp: key.NewBinding(
|
||||
key.WithKeys("pgup"),
|
||||
key.WithHelp("pgup", "page up"),
|
||||
),
|
||||
PageDown: key.NewBinding(
|
||||
key.WithKeys("pgdown"),
|
||||
key.WithHelp("pgdown", "page down"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+c"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
ShowSensitive: key.NewBinding(
|
||||
key.WithKeys("ctrl+s"),
|
||||
key.WithHelp("ctrl+s", "show sensitive"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (k keyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.Down, k.Quit}
|
||||
}
|
||||
|
||||
func (k keyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down},
|
||||
{k.PageUp, k.PageDown},
|
||||
{k.Quit},
|
||||
{k.ShowSensitive},
|
||||
}
|
||||
}
|
||||
|
||||
type privateKeyModel struct {
|
||||
viewport viewport.Model
|
||||
keymap keyMap
|
||||
help help.Model
|
||||
ready bool
|
||||
privateKey openapi.PrivateKey
|
||||
sensitive bool
|
||||
quitting bool
|
||||
err error
|
||||
}
|
||||
|
||||
func newPrivateKeyModel(privateKey openapi.PrivateKey, sensitive bool) privateKeyModel {
|
||||
return privateKeyModel{
|
||||
keymap: defaultKeyMap(),
|
||||
help: help.New(),
|
||||
privateKey: privateKey,
|
||||
sensitive: sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
func (m privateKeyModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m privateKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var (
|
||||
cmd tea.Cmd
|
||||
cmds []tea.Cmd
|
||||
)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keymap.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keymap.Up):
|
||||
m.viewport.LineUp(1)
|
||||
case key.Matches(msg, m.keymap.Down):
|
||||
m.viewport.LineDown(1)
|
||||
case key.Matches(msg, m.keymap.PageUp):
|
||||
m.viewport.HalfViewUp()
|
||||
case key.Matches(msg, m.keymap.PageDown):
|
||||
m.viewport.HalfViewDown()
|
||||
case key.Matches(msg, m.keymap.ShowSensitive):
|
||||
m.sensitive = !m.sensitive
|
||||
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
|
||||
}
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(msg.Width, msg.Height-4)
|
||||
m.viewport.Style = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Padding(0, 2)
|
||||
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
|
||||
m.help.Width = msg.Width
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = msg.Width
|
||||
m.viewport.Height = msg.Height - 4
|
||||
m.help.Width = msg.Width
|
||||
}
|
||||
}
|
||||
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m privateKeyModel) View() string {
|
||||
if !m.ready {
|
||||
return "Initializing..."
|
||||
}
|
||||
if m.err != nil {
|
||||
return fmt.Sprintf("Error: %v\nPress esc to quit", m.err)
|
||||
}
|
||||
|
||||
var s strings.Builder
|
||||
s.WriteString(m.viewport.View())
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(m.help.View(m.keymap))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) newGetCommand() *cobra.Command {
|
||||
var showSensitive bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Short: "Get private key details",
|
||||
Long: `Get the details of a specific private key by its UUID.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uuid := args[0]
|
||||
|
||||
response, err := c.coolify().Client.GetPrivateKeyByUuid(cmd.Context(), uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseGetPrivateKeyByUuidResponse(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if parsedResponse.StatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch private key: %s", string(parsedResponse.Body))
|
||||
}
|
||||
|
||||
key := *parsedResponse.JSON200
|
||||
|
||||
format, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get format: %w", err)
|
||||
}
|
||||
if format == "json" {
|
||||
// Redact sensitive data if --show-sensitive is not set
|
||||
if !showSensitive {
|
||||
// Create a copy with redacted sensitive fields
|
||||
redactedKey := key
|
||||
redactedKey.PrivateKey = &coolTypes.Redacted
|
||||
redactedKey.PublicKey = &coolTypes.Redacted
|
||||
key = redactedKey
|
||||
}
|
||||
|
||||
// For JSON output, directly encode to stdout
|
||||
encoder := json.NewEncoder(os.Stdout)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(key)
|
||||
}
|
||||
|
||||
// Initialize and run Bubble Tea program
|
||||
m := newPrivateKeyModel(key, showSensitive)
|
||||
p := tea.NewProgram(m)
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running program: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add flags
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like key contents")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package cliprivatekeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type filterableListModel struct {
|
||||
FilterableTable *tui.FilterableTable
|
||||
}
|
||||
|
||||
func newFilterableListModel(keys []openapi.PrivateKey, filter string) *filterableListModel {
|
||||
columns := []table.Column{
|
||||
{Title: "UUID", Width: 30},
|
||||
{Title: "Name", Width: 30},
|
||||
{Title: "Created At", Width: 30},
|
||||
}
|
||||
|
||||
return &filterableListModel{
|
||||
FilterableTable: tui.NewTableFilter(wrapKeys(keys), columns, buildRow).
|
||||
WithInitialFilter(filter).
|
||||
WithDetailView(buildDetailView).
|
||||
WithDetailHeader("Private Key Details"),
|
||||
}
|
||||
}
|
||||
|
||||
func wrapKeys(keys []openapi.PrivateKey) []tui.FilterableItem {
|
||||
items := make([]tui.FilterableItem, len(keys))
|
||||
for i, key := range keys {
|
||||
items[i] = &key
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildRow(item tui.FilterableItem) table.Row {
|
||||
key := item.(*openapi.PrivateKey)
|
||||
return table.Row{
|
||||
*key.Uuid,
|
||||
*key.Name,
|
||||
*key.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildDetailView(item tui.FilterableItem, sensitive bool) string {
|
||||
key := item.(*openapi.PrivateKey)
|
||||
var s strings.Builder
|
||||
addSection := func(title string, value interface{}) {
|
||||
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
|
||||
if value != nil {
|
||||
switch v := value.(type) {
|
||||
case *string:
|
||||
if v != nil {
|
||||
s.WriteString(*v + "\n\n")
|
||||
}
|
||||
case *bool:
|
||||
if v != nil {
|
||||
s.WriteString(fmt.Sprintf("%v\n\n", *v))
|
||||
}
|
||||
case *int:
|
||||
if v != nil {
|
||||
s.WriteString(fmt.Sprintf("%d\n\n", *v))
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
s.WriteString("N/A\n\n")
|
||||
}
|
||||
}
|
||||
addSection("UUID", key.Uuid)
|
||||
addSection("Name", key.Name)
|
||||
addSection("Description", key.Description)
|
||||
addSection("Fingerprint", key.Fingerprint)
|
||||
|
||||
if sensitive {
|
||||
addSection("Private Key", key.PrivateKey)
|
||||
addSection("Public Key", key.PublicKey)
|
||||
} else {
|
||||
addSection("Private Key", &coolTypes.Redacted)
|
||||
addSection("Public Key", &coolTypes.Redacted)
|
||||
}
|
||||
|
||||
addSection("Git Related", key.IsGitRelated)
|
||||
addSection("Team ID", key.TeamId)
|
||||
addSection("Created At", key.CreatedAt)
|
||||
addSection("Updated At", key.UpdatedAt)
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m *filterableListModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.FilterableTable.Update(msg)
|
||||
}
|
||||
|
||||
func (m *filterableListModel) View() string {
|
||||
return m.FilterableTable.View()
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) handleDelete(item tui.FilterableItem) error {
|
||||
key := item.(*openapi.PrivateKey)
|
||||
deleteReq, err := c.coolify().Client.DeletePrivateKeyByUuid(context.Background(), *key.Uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(deleteReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
switch parsedResponse.StatusCode() {
|
||||
case http.StatusUnprocessableEntity:
|
||||
return fmt.Errorf("failed to delete private key: %s", *parsedResponse.JSON422.Message)
|
||||
case http.StatusOK:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("failed to delete private key: %s", string(parsedResponse.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) newListCommand() *cobra.Command {
|
||||
var filter string
|
||||
var showSensitive bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [filter]",
|
||||
Short: "List all private keys",
|
||||
Long: `List all SSH private keys registered in your Coolify instance.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s private-keys list --format json
|
||||
%[1]s private-keys list "My Key"
|
||||
%[1]s private-keys list --show-sensitive
|
||||
%[1]s private-keys list # Interactive mode
|
||||
`),
|
||||
SilenceUsage: true,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
filter = args[0]
|
||||
}
|
||||
|
||||
response, err := c.coolify().Client.ListPrivateKeys(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseListPrivateKeysResponse(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if parsedResponse.StatusCode() != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch private keys: %s", string(parsedResponse.Body))
|
||||
}
|
||||
|
||||
keys := *parsedResponse.JSON200
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
if format == "json" {
|
||||
// For JSON output, redact sensitive data if --show-sensitive is not set
|
||||
if !showSensitive {
|
||||
// Create a copy with redacted sensitive fields
|
||||
redactedKeys := make([]openapi.PrivateKey, len(*parsedResponse.JSON200))
|
||||
for i, key := range *parsedResponse.JSON200 {
|
||||
redactedKeys[i] = key
|
||||
redactedKeys[i].PrivateKey = &coolTypes.Redacted
|
||||
redactedKeys[i].PublicKey = &coolTypes.Redacted
|
||||
}
|
||||
keys = redactedKeys
|
||||
}
|
||||
|
||||
// For JSON output, directly encode to stdout
|
||||
encoder := json.NewEncoder(cmd.OutOrStdout())
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(keys)
|
||||
}
|
||||
|
||||
model := newFilterableListModel(keys, filter)
|
||||
model.FilterableTable.WithDeleteHandler(c.handleDelete)
|
||||
p := tea.NewProgram(model)
|
||||
_, err = p.Run()
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like public keys")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cliprivatekeys
|
||||
|
||||
import (
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cliPrivateKeys struct {
|
||||
coolify runtime.Getter
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliPrivateKeys {
|
||||
return &cliPrivateKeys{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cliPrivateKeys) NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "private-keys",
|
||||
Short: "Manage SSH private keys",
|
||||
Long: `Manage SSH private keys for your Coolify instance.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(c.newListCommand())
|
||||
cmd.AddCommand(c.newGetCommand())
|
||||
cmd.AddCommand(c.newAddCommand())
|
||||
cmd.AddCommand(c.newRemoveCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package cliprivatekeys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (c *cliPrivateKeys) newRemoveCommand() *cobra.Command {
|
||||
var forceRemove bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Short: "Remove a private key",
|
||||
Long: `Remove an private key from your Coolify instance.`,
|
||||
SilenceUsage: true,
|
||||
Aliases: []string{"delete", "rm"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uuid := args[0]
|
||||
|
||||
if !forceRemove {
|
||||
fmt.Printf("Are you sure you want to remove the private key with UUID '%s'? [y/N] ", uuid)
|
||||
var confirm string
|
||||
_, err := fmt.Scanln(&confirm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
fmt.Println("Operation canceled")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
req, err := c.coolify().Client.DeletePrivateKeyByUuid(cmd.Context(), uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if parsedResponse.StatusCode() != http.StatusOK {
|
||||
errorMessage := "failed to remove private key"
|
||||
switch parsedResponse.StatusCode() {
|
||||
case http.StatusBadRequest:
|
||||
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON400.Message)
|
||||
case http.StatusUnprocessableEntity:
|
||||
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON422.Message)
|
||||
default:
|
||||
errorMessage = fmt.Sprintf("%s: %s", errorMessage, string(parsedResponse.Body))
|
||||
}
|
||||
return fmt.Errorf("%s", errorMessage)
|
||||
}
|
||||
|
||||
fmt.Println(tui.SuccessStyle.Render("Private key removed successfully"))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Add flags
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&forceRemove, "force", "f", false, "Attempt to remove without confirmation prompt")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// addKeyMap defines keybindings for the add server form
|
||||
type addKeyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Tab key.Binding
|
||||
Enter key.Binding
|
||||
Help key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
// ShortHelp returns keybindings to be shown in the mini help view
|
||||
func (k addKeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Help, k.Quit}
|
||||
}
|
||||
|
||||
// FullHelp returns keybindings for the expanded help view
|
||||
func (k addKeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Tab}, // first column
|
||||
{k.Enter, k.Help}, // second column
|
||||
{k.Quit}, // third column
|
||||
}
|
||||
}
|
||||
|
||||
var addKeys = addKeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up"),
|
||||
key.WithHelp("↑", "move up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down"),
|
||||
key.WithHelp("↓", "move down"),
|
||||
),
|
||||
Tab: key.NewBinding(
|
||||
key.WithKeys("tab", "shift+tab"),
|
||||
key.WithHelp("tab", "next field"),
|
||||
),
|
||||
Enter: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("enter", "submit/select"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "toggle help"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("esc", "ctrl+c"),
|
||||
key.WithHelp("esc", "quit"),
|
||||
),
|
||||
}
|
||||
|
||||
type addModel struct {
|
||||
inputs []textinput.Model
|
||||
focusIndex int
|
||||
err error
|
||||
done bool
|
||||
keys addKeyMap
|
||||
help help.Model
|
||||
}
|
||||
|
||||
func (c *cliServers) newAddCommand() *cobra.Command {
|
||||
var validate bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "add [name] [ip] [private_key_uuid]",
|
||||
Short: "Add a new server",
|
||||
Long: `
|
||||
Add a new server to your Coolify instance.
|
||||
If no arguments are provided, an interactive form will be shown.`,
|
||||
SilenceUsage: true,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s servers add "My Server" 192.168.1.100 abcd1234-uuid
|
||||
%[1]s servers add "Production" 10.0.0.1 efgh5678-uuid --validate
|
||||
%[1]s servers add # Interactive mode`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return c.runInteractiveAdd(validate)
|
||||
}
|
||||
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("requires exactly 3 arguments (name, ip, private_key_uuid) or no arguments for interactive mode")
|
||||
}
|
||||
|
||||
return c.addServer(args[0], args[1], args[2], 22, "root", validate)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&validate, "validate", false, "Validate the server after adding")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (c *cliServers) runInteractiveAdd(validate bool) error {
|
||||
p := tea.NewProgram(initialAddModel())
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running form: %w", err)
|
||||
}
|
||||
|
||||
finalModel := m.(addModel)
|
||||
if !finalModel.done {
|
||||
return fmt.Errorf("operation cancelled")
|
||||
}
|
||||
|
||||
// Get values from the form
|
||||
name := strings.TrimSpace(finalModel.inputs[0].Value())
|
||||
ip := strings.TrimSpace(finalModel.inputs[1].Value())
|
||||
port := strings.TrimSpace(finalModel.inputs[2].Value())
|
||||
user := strings.TrimSpace(finalModel.inputs[3].Value())
|
||||
privateKeyUUID := strings.TrimSpace(finalModel.inputs[4].Value())
|
||||
|
||||
// Convert port to int with default 22
|
||||
portNum := 22
|
||||
if port != "" {
|
||||
if _, err := fmt.Sscanf(port, "%d", &portNum); err != nil {
|
||||
return fmt.Errorf("invalid port number: %s", port)
|
||||
}
|
||||
}
|
||||
|
||||
// Use default user if not specified
|
||||
if user == "" {
|
||||
user = "root"
|
||||
}
|
||||
|
||||
return c.addServer(name, ip, privateKeyUUID, portNum, user, validate)
|
||||
}
|
||||
|
||||
func initialAddModel() addModel {
|
||||
inputs := make([]textinput.Model, 5)
|
||||
|
||||
// Initialize text inputs
|
||||
labels := []string{"Name", "IP Address", "Port (default: 22)", "User (default: root)", "Private Key UUID"}
|
||||
for i := range inputs {
|
||||
input := tui.NewBlurredInput(labels[i], "")
|
||||
inputs[i] = input
|
||||
}
|
||||
|
||||
inputs[0].Focus()
|
||||
return addModel{
|
||||
inputs: inputs,
|
||||
err: nil,
|
||||
keys: addKeys,
|
||||
help: help.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m addModel) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
}
|
||||
|
||||
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
if key.Matches(msg, m.keys.Quit) {
|
||||
m.done = false
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Help) {
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Enter) {
|
||||
// Submit on enter when last input is focused
|
||||
if m.focusIndex == len(m.inputs)-1 {
|
||||
m.done = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
// Otherwise move to next input
|
||||
m.focusIndex++
|
||||
if m.focusIndex >= len(m.inputs) {
|
||||
m.focusIndex = 0
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Tab) {
|
||||
// Cycle focus between inputs
|
||||
if msg.String() == "shift+tab" {
|
||||
m.focusIndex--
|
||||
} else {
|
||||
m.focusIndex++
|
||||
}
|
||||
|
||||
if m.focusIndex >= len(m.inputs) {
|
||||
m.focusIndex = 0
|
||||
} else if m.focusIndex < 0 {
|
||||
m.focusIndex = len(m.inputs) - 1
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Up) {
|
||||
m.focusIndex--
|
||||
if m.focusIndex < 0 {
|
||||
m.focusIndex = len(m.inputs) - 1
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
|
||||
if key.Matches(msg, m.keys.Down) {
|
||||
m.focusIndex++
|
||||
if m.focusIndex >= len(m.inputs) {
|
||||
m.focusIndex = 0
|
||||
}
|
||||
m.updateFocus()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle character input
|
||||
cmd := m.updateInputs(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m *addModel) updateFocus() {
|
||||
for i := 0; i < len(m.inputs); i++ {
|
||||
if i == m.focusIndex {
|
||||
m.inputs[i].Focus()
|
||||
} else {
|
||||
m.inputs[i].Blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *addModel) updateInputs(msg tea.Msg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
|
||||
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m addModel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("Please enter server details:\n\n")
|
||||
|
||||
for i, input := range m.inputs {
|
||||
b.WriteString(input.View())
|
||||
if i < len(m.inputs)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
button := "\n\n"
|
||||
if m.focusIndex == len(m.inputs)-1 {
|
||||
button += lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("99")).
|
||||
Render("[ Submit ]")
|
||||
} else {
|
||||
button += "[ Submit ]"
|
||||
}
|
||||
|
||||
b.WriteString(button)
|
||||
|
||||
// Add help view
|
||||
if m.help.ShowAll {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.help.View(m.keys))
|
||||
} else {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (c *cliServers) addServer(name, ip, privateKeyUUID string, port int, user string, validate bool) error {
|
||||
req, err := c.coolify().Client.CreateServer(context.Background(), openapi.CreateServerJSONRequestBody{
|
||||
Name: &name,
|
||||
Ip: &ip,
|
||||
Port: &port,
|
||||
User: &user,
|
||||
PrivateKeyUuid: &privateKeyUUID,
|
||||
InstantValidate: &validate,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseCreateServerResponse(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if parsedResponse.StatusCode() != http.StatusCreated {
|
||||
return fmt.Errorf("failed to add server: %s", *parsedResponse.JSON400.Message)
|
||||
}
|
||||
|
||||
if validate {
|
||||
fmt.Printf("Server added successfully with uuid %s\n", *parsedResponse.JSON201.Uuid)
|
||||
} else {
|
||||
fmt.Printf("Server added successfully with uuid %s. Server is not validated. Use 'servers validate %s' to validate the server.\n", *parsedResponse.JSON201.Uuid, *parsedResponse.JSON201.Uuid)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type getModel struct {
|
||||
server *openapi.Server
|
||||
sensitive bool
|
||||
withResources bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *cliServers) newGetCommand() *cobra.Command {
|
||||
var withResources bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Short: "Get server details",
|
||||
Long: `
|
||||
Get detailed information about a specific server.
|
||||
Optionally show its resources and sensitive information.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000
|
||||
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --resources
|
||||
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --sensitive
|
||||
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --format json`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uuid := args[0]
|
||||
|
||||
// Fetch server details
|
||||
serverData, err := c.fetchServer(cmd.Context(), uuid, withResources)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch server details: %w", err)
|
||||
}
|
||||
|
||||
outFormat, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get output format: %w", err)
|
||||
}
|
||||
// Handle JSON output format
|
||||
if outFormat == "json" {
|
||||
return json.NewEncoder(os.Stdout).Encode(serverData)
|
||||
}
|
||||
|
||||
// Create and run Bubble Tea program for interactive display
|
||||
p := tea.NewProgram(initialGetModel(serverData))
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running detail view: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVar(&withResources, "resources", false, "Show server resources")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initialGetModel(server *openapi.Server) getModel {
|
||||
return getModel{
|
||||
server: server,
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Bubble Tea Model interface
|
||||
func (m getModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m getModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg, ok := msg.(tea.KeyMsg); ok {
|
||||
if msg.String() == "ctrl+c" || msg.String() == "esc" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m getModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
// Create styles
|
||||
titleStyle := tui.FocusedStyle.
|
||||
Bold(true).
|
||||
MarginBottom(1)
|
||||
|
||||
labelStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("60"))
|
||||
|
||||
valueStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("99"))
|
||||
|
||||
// Server details section
|
||||
s.WriteString(titleStyle.Render("Server Details"))
|
||||
s.WriteString("\n")
|
||||
|
||||
// Helper function to add a field
|
||||
addField := func(label, value string) {
|
||||
s.WriteString(fmt.Sprintf("%s: %s\n",
|
||||
labelStyle.Render(label),
|
||||
valueStyle.Render(value)))
|
||||
}
|
||||
|
||||
addField("UUID", *m.server.Uuid)
|
||||
addField("Name", *m.server.Name)
|
||||
|
||||
addField("IP Address", *m.server.Ip)
|
||||
addField("User", *m.server.User)
|
||||
|
||||
addField("Port", fmt.Sprintf("%d", *m.server.Port))
|
||||
|
||||
status := "Offline"
|
||||
if *m.server.Settings.IsReachable && *m.server.Settings.IsUsable {
|
||||
status = "Online"
|
||||
}
|
||||
addField("Status", status)
|
||||
|
||||
return "\n" + s.String()
|
||||
}
|
||||
|
||||
func (c *cliServers) fetchServer(ctx context.Context, uuid string, withResources bool) (*openapi.Server, error) {
|
||||
|
||||
req, err := c.coolify().Client.GetServerByUuid(ctx, uuid, func(ctx context.Context, req *http.Request) error {
|
||||
if withResources {
|
||||
req.URL.RawQuery = url.Values{"resources": {"true"}}.Encode()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseGetServerByUuidResponse(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if parsedResponse.StatusCode() != http.StatusOK {
|
||||
switch parsedResponse.StatusCode() {
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("failed to get server: %s", *parsedResponse.JSON404.Message)
|
||||
default:
|
||||
return nil, fmt.Errorf("failed to get server: %s", string(parsedResponse.Body))
|
||||
}
|
||||
}
|
||||
|
||||
return parsedResponse.JSON200, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listModel struct {
|
||||
filterableTable *tui.FilterableTable
|
||||
servers *[]openapi.Server
|
||||
sensitive bool
|
||||
filter string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *cliServers) newListCommand() *cobra.Command {
|
||||
var showSensitive bool
|
||||
var initialFilter string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list [filter]",
|
||||
Short: "List all servers",
|
||||
Long: `
|
||||
List all servers registered in your Coolify instance.
|
||||
Use --sensitive to show sensitive information like IP addresses.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s servers list
|
||||
%[1]s servers list "my-server"
|
||||
%[1]s servers list --format json
|
||||
%[1]s servers list --sensitive`),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
initialFilter = args[0]
|
||||
}
|
||||
|
||||
// Fetch servers from API
|
||||
data, err := c.fetchServers(cmd.Context())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch servers: %w", err)
|
||||
}
|
||||
|
||||
outputFormat, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get output format: %w", err)
|
||||
}
|
||||
|
||||
// Handle JSON output format
|
||||
if outputFormat == "json" {
|
||||
return json.NewEncoder(os.Stdout).Encode(data)
|
||||
}
|
||||
|
||||
// Create and run Bubble Tea program for interactive display
|
||||
p := tea.NewProgram(initialListModel(data, showSensitive, initialFilter))
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running list view: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVarP(&showSensitive, "sensitive", "s", false, "Show sensitive information")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initialListModel(servers *[]openapi.Server, sensitive bool, initialFilter string) listModel {
|
||||
columns := []table.Column{
|
||||
{Title: "UUID", Width: 36},
|
||||
{Title: "Name", Width: 30},
|
||||
{Title: "IP Address", Width: 15},
|
||||
}
|
||||
|
||||
// Convert servers to FilterableItems
|
||||
items := make([]tui.FilterableItem, len(*servers))
|
||||
for i, s := range *servers {
|
||||
items[i] = &s
|
||||
}
|
||||
|
||||
// Create row builder function
|
||||
rowBuilder := func(item tui.FilterableItem) table.Row {
|
||||
s := item.(*openapi.Server)
|
||||
|
||||
return table.Row{
|
||||
*s.Uuid,
|
||||
*s.Name,
|
||||
*s.Ip,
|
||||
}
|
||||
}
|
||||
|
||||
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
|
||||
s := item.(*openapi.Server)
|
||||
|
||||
var builder strings.Builder
|
||||
addSection := func(title, value interface{}) {
|
||||
builder.WriteString(tui.FocusedStyle.Bold(true).Render(fmt.Sprintf("%s: ", title)))
|
||||
switch v := value.(type) {
|
||||
case *string:
|
||||
builder.WriteString(*v)
|
||||
case *int:
|
||||
builder.WriteString(fmt.Sprintf("%d", *v))
|
||||
case *openapi.ServerProxyType:
|
||||
if v != nil {
|
||||
builder.WriteString(string(*v))
|
||||
} else {
|
||||
builder.WriteString("N/A")
|
||||
}
|
||||
case string:
|
||||
builder.WriteString(v)
|
||||
case *bool:
|
||||
if v != nil {
|
||||
builder.WriteString(fmt.Sprintf("%t", *v))
|
||||
} else {
|
||||
builder.WriteString("N/A")
|
||||
}
|
||||
}
|
||||
builder.WriteString("\n\n")
|
||||
}
|
||||
|
||||
addSection("UUID", s.Uuid)
|
||||
addSection("Name", s.Name)
|
||||
addSection("IP Address", s.Ip)
|
||||
addSection("User", s.User)
|
||||
addSection("Port", s.Port)
|
||||
addSection("Proxy Type", s.ProxyType)
|
||||
addSection("Settings", "")
|
||||
addSection(" Created At", s.Settings.CreatedAt)
|
||||
addSection(" Updated At", s.Settings.UpdatedAt)
|
||||
addSection(" Server ID", s.Settings.ServerId)
|
||||
addSection(" Concurrent Builds", s.Settings.ConcurrentBuilds)
|
||||
addSection(" Dynamic Timeout", s.Settings.DynamicTimeout)
|
||||
addSection(" Docker", "")
|
||||
addSection(" Delete Unused Networks", s.Settings.DeleteUnusedNetworks)
|
||||
addSection(" Delete Unused Volumes", s.Settings.DeleteUnusedVolumes)
|
||||
addSection(" Cleanup Frequency", s.Settings.DockerCleanupFrequency)
|
||||
addSection(" Cleanup Threshold", s.Settings.DockerCleanupThreshold)
|
||||
addSection(" Force Disabled", s.Settings.ForceDisabled)
|
||||
addSection(" Force Server Cleanup", s.Settings.ForceServerCleanup)
|
||||
addSection(" Is Build Server", s.Settings.IsBuildServer)
|
||||
addSection(" Is Cloudflare Tunnel", s.Settings.IsCloudflareTunnel)
|
||||
addSection(" Is Jump Server", s.Settings.IsJumpServer)
|
||||
if s.Settings.IsLogdrainAxiomEnabled != nil && *s.Settings.IsLogdrainAxiomEnabled {
|
||||
addSection(" Axiom", "")
|
||||
addSection(" API Key", s.Settings.LogdrainAxiomApiKey)
|
||||
addSection(" Dataset Name", s.Settings.LogdrainAxiomDatasetName)
|
||||
}
|
||||
if s.Settings.IsLogdrainCustomEnabled != nil && *s.Settings.IsLogdrainCustomEnabled {
|
||||
addSection(" Custom Drain", "")
|
||||
addSection(" Config", s.Settings.LogdrainCustomConfig)
|
||||
addSection(" Config Parser", s.Settings.LogdrainCustomConfigParser)
|
||||
}
|
||||
if s.Settings.IsLogdrainHighlightEnabled != nil && *s.Settings.IsLogdrainHighlightEnabled {
|
||||
addSection(" Highlight", "")
|
||||
addSection(" Project ID", s.Settings.LogdrainHighlightProjectId)
|
||||
}
|
||||
if s.Settings.IsLogdrainNewrelicEnabled != nil && *s.Settings.IsLogdrainNewrelicEnabled {
|
||||
addSection(" Newrelic", "")
|
||||
addSection(" Base URI", s.Settings.LogdrainNewrelicBaseUri)
|
||||
addSection(" License Key", s.Settings.LogdrainNewrelicLicenseKey)
|
||||
}
|
||||
addSection(" Metrics", "")
|
||||
addSection(" History Days", s.Settings.SentinelMetricsHistoryDays)
|
||||
addSection(" Refresh Rate", s.Settings.SentinelMetricsRefreshRateSeconds)
|
||||
addSection(" Token", s.Settings.SentinelToken)
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
ft := tui.NewTableFilter(items, columns, rowBuilder).
|
||||
WithInitialFilter(initialFilter).
|
||||
WithDetailView(detailBuilder)
|
||||
|
||||
return listModel{
|
||||
filterableTable: ft,
|
||||
servers: servers,
|
||||
sensitive: sensitive,
|
||||
filter: initialFilter,
|
||||
}
|
||||
}
|
||||
|
||||
// Implement Bubble Tea Model interface
|
||||
func (m listModel) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, m.filterableTable.Update(msg)
|
||||
}
|
||||
|
||||
func (m listModel) View() string {
|
||||
return m.filterableTable.View()
|
||||
}
|
||||
|
||||
func (c *cliServers) fetchServers(ctx context.Context) (*[]openapi.Server, error) {
|
||||
req, err := c.coolify().Client.ListServers(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseListServersResponse(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return parsedResponse.JSON200, nil
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func (c *cliServers) newRemoveCommand() *cobra.Command {
|
||||
var force bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Short: "Remove a server",
|
||||
Long: `
|
||||
Remove a server from your Coolify instance.
|
||||
This action cannot be undone.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s servers remove [uuid]
|
||||
%[1]s servers remove [uuid] --force`),
|
||||
Aliases: []string{"delete", "rm"},
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toRemove := args[0]
|
||||
|
||||
if !force {
|
||||
fmt.Printf("Are you sure you want to remove the server with UUID '%s'? [y/N] ", toRemove)
|
||||
var confirm string
|
||||
_, err := fmt.Scanln(&confirm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read confirmation: %w", err)
|
||||
}
|
||||
if confirm != "y" && confirm != "Y" {
|
||||
fmt.Println("Operation cancelled")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
response, err := c.coolify().Client.DeleteServerByUuid(cmd.Context(), toRemove)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove server: %w", err)
|
||||
}
|
||||
parsedResponse, err := openapi.ParseDeleteServerByUuidResponse(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if parsedResponse.StatusCode() != http.StatusOK {
|
||||
switch parsedResponse.StatusCode() {
|
||||
case http.StatusNotFound:
|
||||
return fmt.Errorf("failed to remove server: %s", *parsedResponse.JSON404.Message)
|
||||
default:
|
||||
return fmt.Errorf("failed to remove server: %s", string(parsedResponse.Body))
|
||||
}
|
||||
}
|
||||
fmt.Println(tui.SuccessStyle.Render(*parsedResponse.JSON200.Message))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cliServers struct {
|
||||
coolify runtime.Getter
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliServers {
|
||||
return &cliServers{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCommand creates and returns the servers command
|
||||
func (c *cliServers) NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "servers",
|
||||
Short: "Manage Coolify servers",
|
||||
Long: `
|
||||
Manage servers in your Coolify instance.
|
||||
This command allows you to list, add, remove, and manage servers.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(c.newListCommand())
|
||||
cmd.AddCommand(c.newGetCommand())
|
||||
cmd.AddCommand(c.newAddCommand())
|
||||
cmd.AddCommand(c.newRemoveCommand())
|
||||
cmd.AddCommand(c.newValidateCommand())
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package cliservers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/coollabsio/cli-coolify/cmd/utils"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/coollabsio/cli-coolify/pkg/tui"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type validateModel struct {
|
||||
spinner spinner.Model
|
||||
uuid string
|
||||
done bool
|
||||
err error
|
||||
response string
|
||||
coolify runtime.Getter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type validateSuccessMsg struct {
|
||||
message string
|
||||
}
|
||||
|
||||
type validateErrorMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *cliServers) newValidateCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "validate [uuid]",
|
||||
Short: "Validate server connection",
|
||||
Long: `
|
||||
Validate the connection to a server in your Coolify instance.
|
||||
This will check if the server is reachable and usable.`,
|
||||
Example: utils.GetCommandExample(`
|
||||
%[1]s servers validate 123e4567-e89b-12d3-a456-426614174000`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
uuid := args[0]
|
||||
|
||||
p := tea.NewProgram(initialValidateModel(uuid, c.coolify, cmd.Context()))
|
||||
model, err := p.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error running validation: %w", err)
|
||||
}
|
||||
|
||||
finalModel := model.(validateModel)
|
||||
if finalModel.err != nil {
|
||||
return finalModel.err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func initialValidateModel(uuid string, coolify runtime.Getter, ctx context.Context) validateModel {
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Points
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
|
||||
|
||||
return validateModel{
|
||||
spinner: s,
|
||||
uuid: uuid,
|
||||
coolify: coolify,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (m validateModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
m.validateServer,
|
||||
)
|
||||
}
|
||||
|
||||
func (m validateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
|
||||
case validateSuccessMsg:
|
||||
m.done = true
|
||||
m.response = msg.message
|
||||
return m, tea.Quit
|
||||
|
||||
case validateErrorMsg:
|
||||
m.done = true
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m validateModel) View() string {
|
||||
if m.done {
|
||||
if m.err != nil {
|
||||
return tui.ErrorStyle.Render(fmt.Sprintf("Error: %v\n", m.err))
|
||||
}
|
||||
return tui.SuccessStyle.Render(m.response + "\n")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s Validating server...\n", m.spinner.View())
|
||||
}
|
||||
|
||||
func (m validateModel) validateServer() tea.Msg {
|
||||
// Simulate network delay for better UX
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
server, err := m.coolify().Client.ValidateServerByUuid(m.ctx, m.uuid)
|
||||
if err != nil {
|
||||
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %w", err)}
|
||||
}
|
||||
|
||||
parsedResponse, err := openapi.ParseValidateServerByUuidResponse(server)
|
||||
if err != nil {
|
||||
return validateErrorMsg{err: fmt.Errorf("failed to parse server response: %w", err)}
|
||||
}
|
||||
|
||||
if parsedResponse.StatusCode() != http.StatusCreated {
|
||||
switch parsedResponse.StatusCode() {
|
||||
case http.StatusBadRequest:
|
||||
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON400.Message)}
|
||||
case http.StatusNotFound:
|
||||
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON404.Message)}
|
||||
default:
|
||||
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", string(parsedResponse.Body))}
|
||||
}
|
||||
}
|
||||
|
||||
return validateSuccessMsg{message: string(*parsedResponse.JSON201.Message)}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package cliupdate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
coolifyRuntime "github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/coollabsio/cli-coolify/pkg/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cliUpdate struct {
|
||||
coolify coolifyRuntime.Getter
|
||||
}
|
||||
|
||||
func New(c coolifyRuntime.Getter) *cliUpdate {
|
||||
return &cliUpdate{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cliUpdate) NewCommand() *cobra.Command {
|
||||
var preRelease bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update Coolify CLI",
|
||||
Long: `
|
||||
Update the Coolify CLI to the latest version from GitHub releases.
|
||||
|
||||
By default, the command will update to the latest stable version.
|
||||
Use the --pre-release flag to update to the latest pre-release version.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// we should check if the current version is a pre-release
|
||||
currentVersion := c.coolify().Version
|
||||
isPreRelease := strings.Contains(currentVersion, "-")
|
||||
// Create our custom updater
|
||||
update := updater.New("coollabsio", "cli-coolify", c.coolify().Version)
|
||||
|
||||
// Check for updates
|
||||
c.coolify().Logger.Infof("Checking for updates...")
|
||||
|
||||
// Check if an update is available without performing the update
|
||||
release, hasUpdate, err := update.Check(cmd.Context(), preRelease)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking for updates: %v", err)
|
||||
}
|
||||
|
||||
if isPreRelease && !preRelease && !hasUpdate {
|
||||
c.coolify().Logger.Warnf("You are on a pre-release version of the CLI. Use the --pre-release flag to update to the latest pre-release version.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !hasUpdate {
|
||||
c.coolify().Logger.Infof("You are already on the latest version: %s\n", c.coolify().GetFormattedVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
c.coolify().Logger.Infof("Found new version: v%s (current: %s)\n", release.Version, c.coolify().GetFormattedVersion())
|
||||
|
||||
// Format OS/Arch for display
|
||||
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
|
||||
c.coolify().Logger.Infof("Downloading update for %s...", platform)
|
||||
|
||||
// Perform the update
|
||||
newVersion, err := update.To(cmd.Context(), release)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update failed: %v", err)
|
||||
}
|
||||
|
||||
c.coolify().Logger.Infof("Successfully updated to version v%s\n", newVersion)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
flags.BoolVar(&preRelease, "pre-release", false, "Update to pre-release version")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cliversion
|
||||
|
||||
import (
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type cliVersion struct {
|
||||
coolify runtime.Getter
|
||||
}
|
||||
|
||||
func New(c runtime.Getter) *cliVersion {
|
||||
return &cliVersion{
|
||||
coolify: c,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cliVersion) NewCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "version ",
|
||||
Short: "CLI version",
|
||||
Long: `
|
||||
Print the version of the CLI.
|
||||
`,
|
||||
SilenceUsage: true,
|
||||
Args: cobra.NoArgs,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Println(c.coolify().GetFormattedVersion())
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package coolTypes
|
||||
|
||||
var Redacted = "********"
|
||||
|
||||
type Instance struct {
|
||||
Name string `json:"name"`
|
||||
Default bool `json:"default"`
|
||||
Fqdn string `json:"fqdn"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
-1012
File diff suppressed because it is too large
Load Diff
-357
@@ -1,357 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var deployCmd = &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy related commands",
|
||||
}
|
||||
|
||||
// DeployResultDisplay represents a deploy result for table display
|
||||
type DeployResultDisplay struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
var deployByUuidCmd = &cobra.Command{
|
||||
Use: "uuid <uuid>",
|
||||
Short: "Deploy by uuid",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, uuid, force)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For table format, convert deployment info array to display format
|
||||
if format == output.FormatTable {
|
||||
displays := make([]DeployResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = DeployResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
var deployByNameCmd = &cobra.Command{
|
||||
Use: "name <name>",
|
||||
Short: "Deploy by resource name",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
name := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Find resource by name
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
var matchedUUID string
|
||||
for _, r := range resources {
|
||||
if r.Name == name {
|
||||
matchedUUID = r.UUID
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matchedUUID == "" {
|
||||
return fmt.Errorf("resource with name '%s' not found", name)
|
||||
}
|
||||
|
||||
// Deploy using the found UUID
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Deploy(ctx, matchedUUID, force)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy resource: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For table format, convert deployment info array to display format
|
||||
if format == output.FormatTable {
|
||||
displays := make([]DeployResultDisplay, len(result.Deployments))
|
||||
for i, dep := range result.Deployments {
|
||||
displays[i] = DeployResultDisplay{
|
||||
Message: dep.Message,
|
||||
DeploymentUUID: dep.DeploymentUUID,
|
||||
}
|
||||
}
|
||||
return formatter.Format(displays)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
var deployBatchCmd = &cobra.Command{
|
||||
Use: "batch <name1,name2,...>",
|
||||
Short: "Deploy multiple resources by name",
|
||||
Long: `Deploy multiple resources at once.
|
||||
Provide resource names as comma-separated values.
|
||||
Example: coolify deploy batch app1,app2,app3`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
namesStr := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Parse comma-separated names
|
||||
names := make([]string, 0)
|
||||
for _, name := range strings.Split(namesStr, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no resource names provided")
|
||||
}
|
||||
|
||||
// Find resources by name
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
// Build map of name -> UUID
|
||||
nameToUUID := make(map[string]string)
|
||||
for _, r := range resources {
|
||||
nameToUUID[r.Name] = r.UUID
|
||||
}
|
||||
|
||||
// Validate all names exist
|
||||
var notFound []string
|
||||
for _, name := range names {
|
||||
if _, exists := nameToUUID[name]; !exists {
|
||||
notFound = append(notFound, name)
|
||||
}
|
||||
}
|
||||
if len(notFound) > 0 {
|
||||
return fmt.Errorf("resources not found: %v", notFound)
|
||||
}
|
||||
|
||||
// Deploy all resources
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
|
||||
type result struct {
|
||||
Name string
|
||||
UUID string
|
||||
Success bool
|
||||
Message string
|
||||
Error string
|
||||
}
|
||||
|
||||
results := make([]result, 0, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
uuid := nameToUUID[name]
|
||||
fmt.Printf("Deploying %s...\n", name)
|
||||
|
||||
res, err := deploySvc.Deploy(ctx, uuid, force)
|
||||
if err != nil {
|
||||
results = append(results, result{
|
||||
Name: name,
|
||||
UUID: uuid,
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
fmt.Printf(" ❌ Failed: %v\n", err)
|
||||
} else {
|
||||
// Get first deployment message from the array
|
||||
message := ""
|
||||
if len(res.Deployments) > 0 {
|
||||
message = res.Deployments[0].Message
|
||||
}
|
||||
results = append(results, result{
|
||||
Name: name,
|
||||
UUID: uuid,
|
||||
Success: true,
|
||||
Message: message,
|
||||
})
|
||||
fmt.Printf(" ✅ Success: %s\n", message)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
successCount := 0
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nBatch deployment complete: %d/%d succeeded\n", successCount, len(results))
|
||||
|
||||
if successCount < len(results) {
|
||||
return fmt.Errorf("some deployments failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var listDeploymentsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all deployments",
|
||||
Long: `List all currently running deployments across all resources.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
deployments, err := deploySvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(deployments)
|
||||
},
|
||||
}
|
||||
|
||||
var getDeploymentCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get deployment details by UUID",
|
||||
Long: `Get detailed information about a specific deployment by its UUID.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
deployment, err := deploySvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get deployment: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(deployment)
|
||||
},
|
||||
}
|
||||
|
||||
var cancelDeploymentCmd = &cobra.Command{
|
||||
Use: "cancel <uuid>",
|
||||
Short: "Cancel a deployment by UUID",
|
||||
Long: `Cancel an in-progress deployment. This will stop the deployment process and clean up any temporary resources.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to cancel deployment %s? (yes/no): ", uuid)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Cancel aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
deploySvc := service.NewDeploymentService(client)
|
||||
result, err := deploySvc.Cancel(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel deployment: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(result)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
deployByUuidCmd.Flags().Bool("force", false, "Force deployment")
|
||||
deployByNameCmd.Flags().Bool("force", false, "Force deployment")
|
||||
deployBatchCmd.Flags().Bool("force", false, "Force deployment")
|
||||
cancelDeploymentCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
rootCmd.AddCommand(deployCmd)
|
||||
deployCmd.AddCommand(deployByUuidCmd)
|
||||
deployCmd.AddCommand(deployByNameCmd)
|
||||
deployCmd.AddCommand(deployBatchCmd)
|
||||
deployCmd.AddCommand(listDeploymentsCmd)
|
||||
deployCmd.AddCommand(getDeploymentCmd)
|
||||
deployCmd.AddCommand(cancelDeploymentCmd)
|
||||
}
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
)
|
||||
|
||||
var docsCmd = &cobra.Command{
|
||||
Use: "docs",
|
||||
Short: "Generate documentation",
|
||||
Hidden: true,
|
||||
}
|
||||
|
||||
var manCmd = &cobra.Command{
|
||||
Use: "man",
|
||||
Short: "Generate man pages",
|
||||
Long: `Generate man pages for all Coolify CLI commands.
|
||||
|
||||
The man pages will be written to the specified directory (default: ./man).`,
|
||||
Example: ` coolify docs man
|
||||
coolify docs man --output-dir=/usr/local/share/man/man1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate man pages
|
||||
header := &doc.GenManHeader{
|
||||
Title: "COOLIFY",
|
||||
Section: "1",
|
||||
Source: "Coolify CLI " + Version,
|
||||
}
|
||||
|
||||
if err := doc.GenManTree(rootCmd, header, outputDir); err != nil {
|
||||
return fmt.Errorf("failed to generate man pages: %w", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputDir)
|
||||
fmt.Printf("Man pages generated successfully in: %s\n", absPath)
|
||||
fmt.Println("\nTo install the man pages system-wide:")
|
||||
fmt.Println(" sudo cp man/*.1 /usr/local/share/man/man1/")
|
||||
fmt.Println(" sudo mandb")
|
||||
fmt.Println("\nTo view a man page:")
|
||||
fmt.Println(" man coolify")
|
||||
fmt.Println(" man coolify-servers")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var markdownCmd = &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Generate markdown documentation",
|
||||
Long: `Generate markdown documentation for all Coolify CLI commands.
|
||||
|
||||
The markdown files will be written to the specified directory (default: ./docs).`,
|
||||
Example: ` coolify docs markdown
|
||||
coolify docs markdown --output-dir=./documentation`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
outputDir, _ := cmd.Flags().GetString("output-dir")
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate markdown docs
|
||||
if err := doc.GenMarkdownTree(rootCmd, outputDir); err != nil {
|
||||
return fmt.Errorf("failed to generate markdown docs: %w", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputDir)
|
||||
fmt.Printf("Markdown documentation generated successfully in: %s\n", absPath)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(docsCmd)
|
||||
docsCmd.AddCommand(manCmd)
|
||||
docsCmd.AddCommand(markdownCmd)
|
||||
|
||||
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
|
||||
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var domainsCmd = &cobra.Command{
|
||||
Use: "domain",
|
||||
Aliases: []string{"domains"},
|
||||
Short: "Domain related commands",
|
||||
Long: `List all domains configured across your Coolify resources.`,
|
||||
}
|
||||
|
||||
var listDomainsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all domains",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
domainSvc := service.NewDomainService(client)
|
||||
domains, err := domainSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(domains)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(domainsCmd)
|
||||
domainsCmd.AddCommand(listDomainsCmd)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package emoji
|
||||
|
||||
const (
|
||||
CheckMarkButton = "\u2705" // ✅
|
||||
CrossMark = "\u274c" // ❌
|
||||
)
|
||||
-393
@@ -1,393 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var githubCmd = &cobra.Command{
|
||||
Use: "github",
|
||||
Aliases: []string{"gh", "github-app", "github-apps"},
|
||||
Short: "Manage GitHub App integrations",
|
||||
Long: `Manage GitHub App integrations for private repository deployments.`,
|
||||
}
|
||||
|
||||
var listGitHubAppsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all GitHub App integrations",
|
||||
Long: `List all GitHub App integrations configured in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
apps, err := svc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list GitHub Apps: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(apps)
|
||||
},
|
||||
}
|
||||
|
||||
var getGitHubAppCmd = &cobra.Command{
|
||||
Use: "get <app_uuid>",
|
||||
Short: "Get GitHub App details by UUID",
|
||||
Long: `Get detailed information about a specific GitHub App integration.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
app, err := svc.Get(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get GitHub App: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var createGitHubAppCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a GitHub App integration",
|
||||
Long: `Create a new GitHub App integration. This allows you to deploy private repositories from GitHub.
|
||||
|
||||
Required flags: --name, --api-url, --html-url, --app-id, --installation-id, --client-id, --client-secret, --private-key-uuid
|
||||
|
||||
Example: coolify github create --name "My GitHub App" --api-url "https://api.github.com" --html-url "https://github.com" --app-id 123456 --installation-id 789012 --client-id "Iv1.abc123" --client-secret "secret123" --private-key-uuid "abc-123-def-456"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
apiURL, _ := cmd.Flags().GetString("api-url")
|
||||
htmlURL, _ := cmd.Flags().GetString("html-url")
|
||||
appID, _ := cmd.Flags().GetInt("app-id")
|
||||
installationID, _ := cmd.Flags().GetInt("installation-id")
|
||||
clientID, _ := cmd.Flags().GetString("client-id")
|
||||
clientSecret, _ := cmd.Flags().GetString("client-secret")
|
||||
privateKeyUUID, _ := cmd.Flags().GetString("private-key-uuid")
|
||||
|
||||
req := &models.GitHubAppCreateRequest{
|
||||
Name: name,
|
||||
APIURL: apiURL,
|
||||
HTMLURL: htmlURL,
|
||||
AppID: appID,
|
||||
InstallationID: installationID,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
PrivateKeyUUID: privateKeyUUID,
|
||||
}
|
||||
|
||||
// Optional fields
|
||||
if cmd.Flags().Changed("organization") {
|
||||
org, _ := cmd.Flags().GetString("organization")
|
||||
req.Organization = &org
|
||||
}
|
||||
if cmd.Flags().Changed("custom-user") {
|
||||
user, _ := cmd.Flags().GetString("custom-user")
|
||||
req.CustomUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("custom-port") {
|
||||
port, _ := cmd.Flags().GetInt("custom-port")
|
||||
req.CustomPort = &port
|
||||
}
|
||||
if cmd.Flags().Changed("webhook-secret") {
|
||||
secret, _ := cmd.Flags().GetString("webhook-secret")
|
||||
req.WebhookSecret = &secret
|
||||
}
|
||||
if cmd.Flags().Changed("system-wide") {
|
||||
systemWide, _ := cmd.Flags().GetBool("system-wide")
|
||||
req.IsSystemWide = &systemWide
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
app, err := svc.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub App: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(app)
|
||||
},
|
||||
}
|
||||
|
||||
var updateGitHubAppCmd = &cobra.Command{
|
||||
Use: "update <app_uuid>",
|
||||
Short: "Update a GitHub App integration",
|
||||
Long: `Update an existing GitHub App integration. Provide the app UUID and the fields you want to update.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.GitHubAppUpdateRequest{}
|
||||
|
||||
// Update only fields that were explicitly provided
|
||||
if cmd.Flags().Changed("name") {
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
req.Name = &name
|
||||
}
|
||||
if cmd.Flags().Changed("organization") {
|
||||
org, _ := cmd.Flags().GetString("organization")
|
||||
req.Organization = &org
|
||||
}
|
||||
if cmd.Flags().Changed("api-url") {
|
||||
apiURL, _ := cmd.Flags().GetString("api-url")
|
||||
req.APIURL = &apiURL
|
||||
}
|
||||
if cmd.Flags().Changed("html-url") {
|
||||
htmlURL, _ := cmd.Flags().GetString("html-url")
|
||||
req.HTMLURL = &htmlURL
|
||||
}
|
||||
if cmd.Flags().Changed("custom-user") {
|
||||
user, _ := cmd.Flags().GetString("custom-user")
|
||||
req.CustomUser = &user
|
||||
}
|
||||
if cmd.Flags().Changed("custom-port") {
|
||||
port, _ := cmd.Flags().GetInt("custom-port")
|
||||
req.CustomPort = &port
|
||||
}
|
||||
if cmd.Flags().Changed("app-id") {
|
||||
id, _ := cmd.Flags().GetInt("app-id")
|
||||
req.AppID = &id
|
||||
}
|
||||
if cmd.Flags().Changed("installation-id") {
|
||||
id, _ := cmd.Flags().GetInt("installation-id")
|
||||
req.InstallationID = &id
|
||||
}
|
||||
if cmd.Flags().Changed("client-id") {
|
||||
clientID, _ := cmd.Flags().GetString("client-id")
|
||||
req.ClientID = &clientID
|
||||
}
|
||||
if cmd.Flags().Changed("client-secret") {
|
||||
clientSecret, _ := cmd.Flags().GetString("client-secret")
|
||||
req.ClientSecret = &clientSecret
|
||||
}
|
||||
if cmd.Flags().Changed("webhook-secret") {
|
||||
secret, _ := cmd.Flags().GetString("webhook-secret")
|
||||
req.WebhookSecret = &secret
|
||||
}
|
||||
if cmd.Flags().Changed("private-key-uuid") {
|
||||
uuid, _ := cmd.Flags().GetString("private-key-uuid")
|
||||
req.PrivateKeyUUID = &uuid
|
||||
}
|
||||
if cmd.Flags().Changed("system-wide") {
|
||||
systemWide, _ := cmd.Flags().GetBool("system-wide")
|
||||
req.IsSystemWide = &systemWide
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
err = svc.Update(ctx, appUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update GitHub App: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("GitHub App updated successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteGitHubAppCmd = &cobra.Command{
|
||||
Use: "delete <app_uuid>",
|
||||
Short: "Delete a GitHub App integration",
|
||||
Long: `Delete a GitHub App integration. The app must not be used by any applications.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete GitHub App %s? This cannot be undone. (yes/no): ", appUUID)
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
err = svc.Delete(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete GitHub App: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("GitHub App deleted successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var listRepositoriesCmd = &cobra.Command{
|
||||
Use: "repos <app_uuid>",
|
||||
Short: "List repositories accessible by a GitHub App",
|
||||
Long: `List all repositories that are accessible by the specified GitHub App.`,
|
||||
Args: exactArgs(1, "<app_uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
repos, err := svc.ListRepositories(ctx, appUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(repos)
|
||||
},
|
||||
}
|
||||
|
||||
var listBranchesCmd = &cobra.Command{
|
||||
Use: "branches <app_uuid> <owner/repo>",
|
||||
Short: "List branches for a repository",
|
||||
Long: `List all branches for a specific repository. Provide the app UUID and repository in owner/repo format.
|
||||
|
||||
Example: coolify github branches abc-123-def owner/repository`,
|
||||
Args: exactArgs(2, "<app_uuid> <owner/repo>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
appUUID := args[0]
|
||||
|
||||
// Parse owner/repo
|
||||
ownerRepo := args[1]
|
||||
parts := splitOwnerRepo(ownerRepo)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid repository format. Expected 'owner/repo', got '%s'", ownerRepo)
|
||||
}
|
||||
owner, repo := parts[0], parts[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
svc := service.NewGitHubAppService(client)
|
||||
branches, err := svc.ListBranches(ctx, appUUID, owner, repo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list branches: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(branches)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Create command flags
|
||||
createGitHubAppCmd.Flags().String("name", "", "GitHub App name (required)")
|
||||
createGitHubAppCmd.Flags().String("organization", "", "GitHub organization")
|
||||
createGitHubAppCmd.Flags().String("api-url", "", "GitHub API URL (required, e.g., https://api.github.com)")
|
||||
createGitHubAppCmd.Flags().String("html-url", "", "GitHub HTML URL (required, e.g., https://github.com)")
|
||||
createGitHubAppCmd.Flags().String("custom-user", "", "Custom user for SSH (default: git)")
|
||||
createGitHubAppCmd.Flags().Int("custom-port", 0, "Custom port for SSH (default: 22)")
|
||||
createGitHubAppCmd.Flags().Int("app-id", 0, "GitHub App ID (required)")
|
||||
createGitHubAppCmd.Flags().Int("installation-id", 0, "GitHub Installation ID (required)")
|
||||
createGitHubAppCmd.Flags().String("client-id", "", "GitHub OAuth Client ID (required)")
|
||||
createGitHubAppCmd.Flags().String("client-secret", "", "GitHub OAuth Client Secret (required)")
|
||||
createGitHubAppCmd.Flags().String("webhook-secret", "", "GitHub Webhook Secret")
|
||||
createGitHubAppCmd.Flags().String("private-key-uuid", "", "UUID of existing private key (required)")
|
||||
createGitHubAppCmd.Flags().Bool("system-wide", false, "Is this app system-wide (cloud only)")
|
||||
|
||||
createGitHubAppCmd.MarkFlagRequired("name")
|
||||
createGitHubAppCmd.MarkFlagRequired("api-url")
|
||||
createGitHubAppCmd.MarkFlagRequired("html-url")
|
||||
createGitHubAppCmd.MarkFlagRequired("app-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("installation-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("client-id")
|
||||
createGitHubAppCmd.MarkFlagRequired("client-secret")
|
||||
createGitHubAppCmd.MarkFlagRequired("private-key-uuid")
|
||||
|
||||
// Update command flags (all optional)
|
||||
updateGitHubAppCmd.Flags().String("name", "", "GitHub App name")
|
||||
updateGitHubAppCmd.Flags().String("organization", "", "GitHub organization")
|
||||
updateGitHubAppCmd.Flags().String("api-url", "", "GitHub API URL")
|
||||
updateGitHubAppCmd.Flags().String("html-url", "", "GitHub HTML URL")
|
||||
updateGitHubAppCmd.Flags().String("custom-user", "", "Custom user for SSH")
|
||||
updateGitHubAppCmd.Flags().Int("custom-port", 0, "Custom port for SSH")
|
||||
updateGitHubAppCmd.Flags().Int("app-id", 0, "GitHub App ID")
|
||||
updateGitHubAppCmd.Flags().Int("installation-id", 0, "GitHub Installation ID")
|
||||
updateGitHubAppCmd.Flags().String("client-id", "", "GitHub OAuth Client ID")
|
||||
updateGitHubAppCmd.Flags().String("client-secret", "", "GitHub OAuth Client Secret")
|
||||
updateGitHubAppCmd.Flags().String("webhook-secret", "", "GitHub Webhook Secret")
|
||||
updateGitHubAppCmd.Flags().String("private-key-uuid", "", "UUID of private key")
|
||||
updateGitHubAppCmd.Flags().Bool("system-wide", false, "Is this app system-wide")
|
||||
|
||||
// Delete command flags
|
||||
deleteGitHubAppCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
|
||||
rootCmd.AddCommand(githubCmd)
|
||||
githubCmd.AddCommand(listGitHubAppsCmd)
|
||||
githubCmd.AddCommand(getGitHubAppCmd)
|
||||
githubCmd.AddCommand(createGitHubAppCmd)
|
||||
githubCmd.AddCommand(updateGitHubAppCmd)
|
||||
githubCmd.AddCommand(deleteGitHubAppCmd)
|
||||
githubCmd.AddCommand(listRepositoriesCmd)
|
||||
githubCmd.AddCommand(listBranchesCmd)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var contextCmd = &cobra.Command{
|
||||
Use: "context",
|
||||
Short: "Manage Coolify contexts (instance configurations)",
|
||||
Long: `Manage Coolify contexts. A context contains the configuration (URL and token) for a Coolify instance.`,
|
||||
}
|
||||
|
||||
var contextVersionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Get current context's Coolify version",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Get version using API client
|
||||
version, err := client.GetVersion(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get version: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var listContextsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all configured contexts",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
|
||||
if PrettyMode {
|
||||
var prettyJSON bytes.Buffer
|
||||
instancesBytes, err := json.Marshal(instances)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
err = json.Indent(&prettyJSON, instancesBytes, "", "\t")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(prettyJSON.String())
|
||||
return
|
||||
}
|
||||
if JsonMode {
|
||||
instancesBytes, err := json.Marshal(instances)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(instancesBytes))
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "#\tName\tFqdn\tToken\tDefault")
|
||||
for index, entry := range instances {
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
fmt.Println("Error")
|
||||
return
|
||||
}
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", index+1, entryMap["name"], entryMap["fqdn"], entryMap["token"], map[bool]string{true: "true", false: ""}[entryMap["default"] == true])
|
||||
} else {
|
||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", index+1, entryMap["name"], entryMap["fqdn"], SensitiveInformationOverlay, map[bool]string{true: "true", false: ""}[entryMap["default"] == true])
|
||||
}
|
||||
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
},
|
||||
}
|
||||
var addContextCmd = &cobra.Command{
|
||||
Use: "add <name> <url> <token>",
|
||||
Example: `context add myserver https://coolify.example.com your-api-token`,
|
||||
Args: exactArgs(3, "<name> <url> <token>"),
|
||||
Short: "Add a new context",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
Host := args[1]
|
||||
Token := args[2]
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
if force {
|
||||
instanceMap["token"] = Token
|
||||
if SetDefaultInstance {
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
delete(instanceMap, "default")
|
||||
}
|
||||
instanceMap["default"] = true
|
||||
fmt.Printf("%s already exists. Force overwriting. Setting it as default. \n", Name)
|
||||
} else {
|
||||
fmt.Printf("%s already exists. Force overwriting. \n", Name)
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s already exists. \n", Name)
|
||||
fmt.Println("\nNote: Use --force to force overwrite.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
instances = append(instances, map[string]interface{}{
|
||||
"name": Name,
|
||||
"fqdn": Host,
|
||||
"token": Token,
|
||||
})
|
||||
|
||||
if SetDefaultInstance {
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
delete(instanceMap, "default")
|
||||
}
|
||||
instances[len(instances)-1].(map[string]interface{})["default"] = true
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var deleteContextCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
Example: `context delete myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Delete a context",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for i, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
instances = append(instances[:i], instances[i+1:]...)
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
fmt.Printf("%s removed. \n", Name)
|
||||
if instanceMap["default"] == true {
|
||||
fmt.Println("Note: The default instance has been removed.")
|
||||
if len(instances) > 0 {
|
||||
instances[0].(map[string]interface{})["default"] = true
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
fmt.Printf("%s set as default. \n", instances[0].(map[string]interface{})["fqdn"])
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s not found. \n", Name)
|
||||
},
|
||||
}
|
||||
var setTokenCmd = &cobra.Command{
|
||||
Use: "set-token <name> <token>",
|
||||
Example: `context set-token myserver your-new-api-token`,
|
||||
Args: exactArgs(2, "<name> <token>"),
|
||||
Short: "Update the API token for a context",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name = args[0]
|
||||
Token = args[1]
|
||||
var found interface{}
|
||||
for _, instance := range viper.Get("instances").([]interface{}) {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
found = instanceMap
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
fmt.Printf("%s instance is not found. \n", Name)
|
||||
return
|
||||
}
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
instanceMap["token"] = Token
|
||||
}
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var useContextCmd = &cobra.Command{
|
||||
Use: "use <name>",
|
||||
Example: `context use myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Switch to a different context (set as default)",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
var found interface{}
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
found = instanceMap
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
fmt.Printf("%s not found. \n", Name)
|
||||
return
|
||||
}
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
instanceMap["default"] = true
|
||||
} else {
|
||||
delete(instanceMap, "default")
|
||||
}
|
||||
}
|
||||
viper.Set("instances", instances)
|
||||
viper.WriteConfig()
|
||||
listContextsCmd.Run(cmd, args)
|
||||
},
|
||||
}
|
||||
var getContextCmd = &cobra.Command{
|
||||
Use: "get <name>",
|
||||
Example: `context get myserver`,
|
||||
Args: exactArgs(1, "<name>"),
|
||||
Short: "Get details of a specific context",
|
||||
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
Name := args[0]
|
||||
instances := viper.Get("instances").([]interface{})
|
||||
if PrettyMode {
|
||||
var prettyJSON bytes.Buffer
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
instanceMap["token"] = SensitiveInformationOverlay
|
||||
}
|
||||
instancesBytes, err := json.Marshal(instances)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
err = json.Indent(&prettyJSON, instancesBytes, "", "\t")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(prettyJSON.String())
|
||||
return
|
||||
}
|
||||
if JsonMode {
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
instanceMap["token"] = SensitiveInformationOverlay
|
||||
}
|
||||
instancesBytes, err := json.Marshal(instances)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(string(instancesBytes))
|
||||
return
|
||||
}
|
||||
for _, instance := range instances {
|
||||
instanceMap := instance.(map[string]interface{})
|
||||
if instanceMap["name"] == Name {
|
||||
fmt.Fprintln(w, "Name\tHost\tToken")
|
||||
if ShowSensitive {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", Name, instanceMap["fqdn"], instanceMap["token"])
|
||||
} else {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", Name, instanceMap["fqdn"], SensitiveInformationOverlay)
|
||||
}
|
||||
w.Flush()
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s not found. \n", Name)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
addContextCmd.Flags().BoolVarP(&SetDefaultInstance, "default", "d", false, "Set as default context")
|
||||
addContextCmd.Flags().BoolP("force", "f", false, "Force overwrite if context already exists")
|
||||
|
||||
rootCmd.AddCommand(contextCmd)
|
||||
contextCmd.AddCommand(contextVersionCmd)
|
||||
contextCmd.AddCommand(listContextsCmd)
|
||||
contextCmd.AddCommand(addContextCmd)
|
||||
contextCmd.AddCommand(deleteContextCmd)
|
||||
contextCmd.AddCommand(setTokenCmd)
|
||||
contextCmd.AddCommand(useContextCmd)
|
||||
contextCmd.AddCommand(getContextCmd)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var privateKeysCmd = &cobra.Command{
|
||||
Use: "private-key",
|
||||
Aliases: []string{"private-keys", "key", "keys"},
|
||||
Short: "Private key related commands",
|
||||
Long: `Manage SSH private keys for server authentication - list, add, and remove keys.`,
|
||||
}
|
||||
|
||||
var listPrivateKeysCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all private keys",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
keys, err := keySvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list private keys: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := formatter.Format(keys); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var addPrivateKeyCmd = &cobra.Command{
|
||||
Use: "add <name> <private_key_or_file>",
|
||||
Example: `add mykey ~/.ssh/id_rsa`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
Short: "Add a private key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
name := args[0]
|
||||
privateKeyInput := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
var privateKey string
|
||||
// Check if input is a file path
|
||||
if _, err := os.Stat(privateKeyInput); err == nil {
|
||||
keyBytes, err := os.ReadFile(privateKeyInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading private key file: %w", err)
|
||||
}
|
||||
privateKey = string(keyBytes)
|
||||
} else {
|
||||
privateKey = privateKeyInput
|
||||
}
|
||||
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
req := models.PrivateKeyCreateRequest{
|
||||
Name: name,
|
||||
PrivateKey: privateKey,
|
||||
}
|
||||
|
||||
key, err := keySvc.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add private key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Private key '%s' added successfully (UUID: %s)\n", key.Name, key.UUID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var removePrivateKeyCmd = &cobra.Command{
|
||||
Use: "remove <uuid>",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Remove a private key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
keySvc := service.NewPrivateKeyService(client)
|
||||
err = keySvc.Delete(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove private key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Private key removed successfully")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(privateKeysCmd)
|
||||
privateKeysCmd.AddCommand(listPrivateKeysCmd)
|
||||
privateKeysCmd.AddCommand(addPrivateKeyCmd)
|
||||
privateKeysCmd.AddCommand(removePrivateKeyCmd)
|
||||
}
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// EnvironmentRow represents an environment for display
|
||||
type EnvironmentRow struct {
|
||||
UUID string `json:"environment_uuid"`
|
||||
EnvironmentName string `json:"environment_name"`
|
||||
}
|
||||
|
||||
// ProjectListRow represents a project for list display (without environments)
|
||||
type ProjectListRow struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var projectsCmd = &cobra.Command{
|
||||
Use: "project",
|
||||
Aliases: []string{"projects"},
|
||||
Short: "Project related commands",
|
||||
Long: `Manage Coolify projects - list all projects or get details about a specific project.`,
|
||||
}
|
||||
|
||||
var listProjectsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all projects",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
projectSvc := service.NewProjectService(client)
|
||||
projects, err := projectSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list projects: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full project structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(projects)
|
||||
}
|
||||
|
||||
// For table format, convert to simplified rows without environments
|
||||
var rows []ProjectListRow
|
||||
for _, p := range projects {
|
||||
desc := ""
|
||||
if p.Description != nil {
|
||||
desc = *p.Description
|
||||
}
|
||||
rows = append(rows, ProjectListRow{
|
||||
UUID: p.UUID,
|
||||
Name: p.Name,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
|
||||
var oneProjectCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Short: "Get a project by uuid",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
projectSvc := service.NewProjectService(client)
|
||||
project, err := projectSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get project: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// For JSON/pretty formats, return the full project structure
|
||||
if format != output.FormatTable {
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return formatter.Format(project)
|
||||
}
|
||||
|
||||
// For table format, expand environments into separate rows
|
||||
rows := expandProjectEnvironments(project)
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(rows)
|
||||
},
|
||||
}
|
||||
|
||||
// expandProjectEnvironments creates environment rows for display
|
||||
func expandProjectEnvironments(project *models.Project) []EnvironmentRow {
|
||||
var rows []EnvironmentRow
|
||||
|
||||
// If no environments, return empty list
|
||||
if len(project.Environments) == 0 {
|
||||
return rows
|
||||
}
|
||||
|
||||
// Create one row per environment with just UUID and Name
|
||||
for _, env := range project.Environments {
|
||||
rows = append(rows, EnvironmentRow{
|
||||
UUID: env.UUID,
|
||||
EnvironmentName: env.Name,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(projectsCmd)
|
||||
projectsCmd.AddCommand(listProjectsCmd)
|
||||
projectsCmd.AddCommand(oneProjectCmd)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var resourcesCmd = &cobra.Command{
|
||||
Use: "resource",
|
||||
Aliases: []string{"resources"},
|
||||
Short: "Resource related commands",
|
||||
Long: `List all resources (applications, services, databases) across your Coolify instance.`,
|
||||
}
|
||||
|
||||
var listResourcesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all resources",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
resourceSvc := service.NewResourceService(client)
|
||||
resources, err := resourceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list resources: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return formatter.Format(resources)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(resourcesCmd)
|
||||
resourcesCmd.AddCommand(listResourcesCmd)
|
||||
}
|
||||
+58
-284
@@ -1,313 +1,87 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/config"
|
||||
compareVersion "github.com/hashicorp/go-version"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliinit"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliinstances"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliprivatekeys"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliservers"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliupdate"
|
||||
"github.com/coollabsio/cli-coolify/cmd/cliversion"
|
||||
"github.com/coollabsio/cli-coolify/cmd/runtime"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// CliVersion is the CLI version
|
||||
var CliVersion = "1.0.0"
|
||||
|
||||
// CheckInterval for version checking
|
||||
var CheckInterval = 10 * time.Minute
|
||||
|
||||
// SensitiveInformationOverlay is the string used to hide sensitive data
|
||||
var SensitiveInformationOverlay = "********"
|
||||
|
||||
// Legacy global variables - kept for backward compatibility during migration
|
||||
// TODO: Remove these once all commands are refactored
|
||||
var (
|
||||
Version string
|
||||
Name string
|
||||
Fqdn string
|
||||
Token string
|
||||
ContextName string
|
||||
Debug bool
|
||||
ShowSensitive bool
|
||||
Format string
|
||||
JsonMode bool
|
||||
PrettyMode bool
|
||||
SetDefaultInstance bool
|
||||
w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
Instance http.Client
|
||||
coolify *runtime.Coolify
|
||||
)
|
||||
|
||||
// Tag represents a git tag for version checking
|
||||
type Tag struct {
|
||||
Ref string `json:"ref"`
|
||||
type cliRoot struct {
|
||||
outputFormat string
|
||||
fqdn string
|
||||
token string
|
||||
name string
|
||||
timeout time.Duration
|
||||
insecure bool
|
||||
logLevel string
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "coolify",
|
||||
Short: "Coolify CLI",
|
||||
Long: `A CLI tool to interact with Coolify API.`,
|
||||
SilenceUsage: true, // Don't show usage on errors
|
||||
SilenceErrors: false, // Still print errors
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
},
|
||||
func NewCliRoot() *cliRoot {
|
||||
return &cliRoot{}
|
||||
}
|
||||
|
||||
// getAPIClient creates an API client from command flags or config
|
||||
func getAPIClient(cmd *cobra.Command) (*api.Client, error) {
|
||||
// Get flags
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
contextName, _ := cmd.Flags().GetString("context")
|
||||
debug, _ := cmd.Flags().GetBool("debug")
|
||||
|
||||
// Load config to get instance details
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
var instance *config.Instance
|
||||
// Use context if specified, otherwise use default
|
||||
if contextName != "" {
|
||||
instance, err = cfg.GetInstance(contextName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("context '%s' not found: %w", contextName, err)
|
||||
}
|
||||
} else {
|
||||
instance, err = cfg.GetDefault()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no default instance configured: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get FQDN from instance
|
||||
fqdn := instance.FQDN
|
||||
|
||||
// Use token from flag if provided, otherwise use instance token
|
||||
if token == "" {
|
||||
token = instance.Token
|
||||
}
|
||||
|
||||
// Create client
|
||||
client := api.NewClient(fqdn, token, api.WithDebug(debug))
|
||||
|
||||
// Set legacy global variables for backward compatibility
|
||||
Fqdn = fqdn
|
||||
Token = token
|
||||
Debug = debug
|
||||
|
||||
return client, nil
|
||||
func (cli *cliRoot) runtime() *runtime.Coolify {
|
||||
return coolify
|
||||
}
|
||||
|
||||
// exactArgs returns a validator that ensures exactly n arguments are provided with a helpful error message
|
||||
func exactArgs(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) != n {
|
||||
if n == 1 {
|
||||
return fmt.Errorf("missing required argument: %s\n\nUsage: %s", usage, cmd.UseLine())
|
||||
}
|
||||
return fmt.Errorf("expected %d argument(s), got %d\n\nUsage: %s", n, len(args), cmd.UseLine())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (cli *cliRoot) initialize() error {
|
||||
coolify = runtime.NewCoolify(cli.fqdn, cli.token, cli.logLevel)
|
||||
|
||||
// Log initialization message
|
||||
coolify.LogTrace("Initializing Coolify CLI with log level: %s", cli.logLevel)
|
||||
|
||||
// Use the new load method on the Coolify struct
|
||||
return coolify.Load(cli.name)
|
||||
}
|
||||
|
||||
// minArgs returns a validator that ensures at least n arguments are provided with a helpful error message
|
||||
func minArgs(n int, usage string) cobra.PositionalArgs {
|
||||
return func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) < n {
|
||||
return fmt.Errorf("missing required arguments: %s\n\nUsage: %s", usage, cmd.UseLine())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseInt parses a string to int with better error message
|
||||
func parseInt(s string) (int, error) {
|
||||
var result int
|
||||
_, err := fmt.Sscanf(s, "%d", &result)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("'%s' is not a valid integer", s)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// splitOwnerRepo splits owner/repo string into parts
|
||||
func splitOwnerRepo(s string) []string {
|
||||
parts := make([]string, 0, 2)
|
||||
var current string
|
||||
for _, char := range s {
|
||||
if char == '/' {
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
current = ""
|
||||
}
|
||||
} else {
|
||||
current += string(char)
|
||||
}
|
||||
}
|
||||
if current != "" {
|
||||
parts = append(parts, current)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// CheckLatestVersionOfCli checks for CLI updates
|
||||
func CheckLatestVersionOfCli() (string, error) {
|
||||
lastCheck := viper.GetString("lastupdatechecktime")
|
||||
if lastCheck != "" {
|
||||
lastCheckTime, err := time.Parse(time.RFC3339, lastCheck)
|
||||
if err == nil && lastCheckTime.Add(CheckInterval).After(time.Now()) {
|
||||
if Debug {
|
||||
log.Println("Skipping update check. Last check was less than 10 minutes ago.")
|
||||
}
|
||||
return CliVersion, nil
|
||||
}
|
||||
func (cli *cliRoot) NewCommand() (*cobra.Command, error) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "coolify",
|
||||
Short: "Coolify CLI",
|
||||
Long: `A CLI tool to interact with Coolify API.`,
|
||||
}
|
||||
|
||||
// Update check time
|
||||
viper.Set("lastupdatechecktime", time.Now().Format(time.RFC3339))
|
||||
viper.WriteConfig()
|
||||
pFlags := cmd.PersistentFlags()
|
||||
|
||||
url := "https://api.github.com/repos/coollabsio/coolify-cli/git/refs/tags"
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
pFlags.StringVar(&cli.token, "token", "", "Token for authentication (https://app.coolify.io/security/api-tokens)")
|
||||
pFlags.StringVar(&cli.fqdn, "host", "", "Coolify instance hostname EG: https://app.coolify.io")
|
||||
pFlags.StringVarP(&cli.name, "name", "n", "", "Name of the instance to use from configuration file")
|
||||
pFlags.StringVar(&cli.outputFormat, "format", "table", "Format output (table|json|pretty)")
|
||||
pFlags.Bool("disableColor", false, "Disable color output for table format")
|
||||
pFlags.DurationVar(&cli.timeout, "timeout", 30*time.Second, "HTTP client timeout")
|
||||
pFlags.BoolVar(&cli.insecure, "insecure", false, "Skip TLS verification")
|
||||
pFlags.StringVar(&cli.logLevel, "log-level", "info", "Set log level (trace|debug|info|warn|error|fatal|panic)")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
cmd.AddCommand(cliinit.New(cli.runtime).NewCommand())
|
||||
cmd.AddCommand(cliinstances.New(cli.runtime).NewCommand())
|
||||
cmd.AddCommand(cliversion.New(cli.runtime).NewCommand())
|
||||
cmd.AddCommand(cliupdate.New(cli.runtime).NewCommand())
|
||||
cmd.AddCommand(cliprivatekeys.New(cli.runtime).NewCommand())
|
||||
cmd.AddCommand(cliservers.New(cli.runtime).NewCommand())
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%d - Failed to fetch data from %s. Error: %s", resp.StatusCode, url, string(body))
|
||||
}
|
||||
|
||||
var tags []Tag
|
||||
if err := json.Unmarshal(body, &tags); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
versionsRaw := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
versionStr := tag.Ref[10:]
|
||||
versionsRaw = append(versionsRaw, versionStr)
|
||||
}
|
||||
|
||||
versions := make([]*compareVersion.Version, len(versionsRaw))
|
||||
for i, raw := range versionsRaw {
|
||||
v, err := compareVersion.NewVersion(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
versions[i] = v
|
||||
}
|
||||
|
||||
sort.Sort(compareVersion.Collection(versions))
|
||||
latestVersion := versions[len(versions)-1]
|
||||
|
||||
// Compare versions properly using semantic versioning
|
||||
currentVersion, err := compareVersion.NewVersion(CliVersion)
|
||||
if err != nil {
|
||||
return latestVersion.String(), err
|
||||
}
|
||||
|
||||
if latestVersion.GreaterThan(currentVersion) {
|
||||
fmt.Printf("There is a new version of Coolify CLI available.\nPlease update with 'coolify update'.\n\n")
|
||||
}
|
||||
return latestVersion.String(), nil
|
||||
}
|
||||
|
||||
// Execute runs the root command
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&Token, "token", "", "", "Token for authentication (override context token)")
|
||||
rootCmd.PersistentFlags().StringVarP(&ContextName, "context", "", "", "Use specific context by name")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&Format, "format", "", "table", "Format output (table|json|pretty)")
|
||||
rootCmd.PersistentFlags().BoolVarP(&ShowSensitive, "show-sensitive", "s", false, "Show sensitive information")
|
||||
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "", false, "Debug mode")
|
||||
}
|
||||
|
||||
func initConfig() {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("json")
|
||||
viper.AddConfigPath(config.Path()[:len(config.Path())-len("/config.json")])
|
||||
|
||||
// Ensure config directory exists
|
||||
configDir := config.Path()[:len(config.Path())-len("/config.json")]
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(configDir, 0755)
|
||||
}
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
log.Println("Config file not found. Creating a new one at", config.Path())
|
||||
if err := config.CreateDefault(); err != nil {
|
||||
log.Printf("Failed to create default config: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Reload config after creating default
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
log.Printf("Failed to read newly created config: %v\n", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Error reading config file:", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if Debug {
|
||||
log.Println("Using config file:", viper.ConfigFileUsed())
|
||||
}
|
||||
|
||||
// Note: We don't pre-populate Fqdn/Token here anymore
|
||||
// They are loaded on-demand by getAPIClient() based on --instance or default instance
|
||||
// This allows --instance flag to work correctly
|
||||
|
||||
// Check for updates
|
||||
latestVersionStr, err := CheckLatestVersionOfCli()
|
||||
if err != nil {
|
||||
if Debug {
|
||||
log.Println("Failed to check for updates:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compare versions properly using semantic versioning
|
||||
if latestVersionStr != "" {
|
||||
latestVersion, err := compareVersion.NewVersion(latestVersionStr)
|
||||
if err == nil {
|
||||
currentVersion, err := compareVersion.NewVersion(CliVersion)
|
||||
if err == nil && latestVersion.GreaterThan(currentVersion) {
|
||||
if Debug {
|
||||
log.Printf("New version of Coolify CLI is available: %s\n", latestVersionStr)
|
||||
if len(os.Args) > 1 {
|
||||
cobra.OnInitialize(
|
||||
func() {
|
||||
if err := cli.initialize(); err != nil {
|
||||
// handle it in future
|
||||
log.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
|
||||
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Package runtime provides a reuseable struct that holds configuration, http client and other common functions shared by all the commands.
|
||||
|
||||
var (
|
||||
// Version will be injected during build by goreleaser, without the 'v' prefix
|
||||
Version = "0.0.0-dev"
|
||||
DefaultConfigDirectory string = xdg.ConfigHome // Currently using xdg.ConfigHome but maybe we can expose this as a flag in future.
|
||||
)
|
||||
|
||||
type Getter func() *Coolify
|
||||
|
||||
type Config struct {
|
||||
Directory string
|
||||
FQDN string
|
||||
Token string
|
||||
JsonExists bool
|
||||
Timeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
type Coolify struct {
|
||||
Version string
|
||||
Config Config
|
||||
Client *openapi.Client
|
||||
Logger *logrus.Logger
|
||||
}
|
||||
|
||||
func NewCoolify(fqdn, token, logLevel string) *Coolify {
|
||||
|
||||
// Initialize logger with default settings
|
||||
logger := logrus.New()
|
||||
logger.SetFormatter(&logrus.TextFormatter{
|
||||
DisableTimestamp: true,
|
||||
})
|
||||
|
||||
// Create the Coolify instance
|
||||
coolify := &Coolify{
|
||||
Version: Version,
|
||||
Config: Config{
|
||||
Directory: DefaultConfigDirectory,
|
||||
FQDN: fqdn,
|
||||
Token: token,
|
||||
JsonExists: false,
|
||||
Timeout: 30 * time.Second,
|
||||
Insecure: false,
|
||||
},
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
// Set the log level immediately
|
||||
if logLevel != "" {
|
||||
coolify.SetLogLevel(logLevel)
|
||||
}
|
||||
|
||||
return coolify
|
||||
}
|
||||
|
||||
func (c *Coolify) ConfigureClient() error {
|
||||
withApiPrefix := fmt.Sprintf("%s/api/v1", c.Config.FQDN)
|
||||
client, err := openapi.NewClient(withApiPrefix)
|
||||
if err != nil {
|
||||
c.LogError("Failed to create client: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add token to all requests via client interceptor
|
||||
client.RequestEditors = append(client.RequestEditors, func(ctx context.Context, req *http.Request) error {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Config.Token)
|
||||
return nil
|
||||
})
|
||||
|
||||
c.Client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFormattedVersion returns the version with 'v' prefix for display
|
||||
func (c *Coolify) GetFormattedVersion() string {
|
||||
// Tags on GitHub don't have 'v' prefix, but we want to display it
|
||||
return fmt.Sprintf("v%s", c.Version)
|
||||
}
|
||||
|
||||
// Load reads the configuration file from the default directory and loads it into the Coolify struct.
|
||||
func (c *Coolify) Load(instanceName string) error {
|
||||
baseDir := path.Join(c.Config.Directory, "coolify")
|
||||
viper.SetConfigType("json")
|
||||
viper.AddConfigPath(baseDir)
|
||||
|
||||
c.LogDebug("Loading configuration from: %s", baseDir)
|
||||
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
c.LogDebug("Configuration directory does not exist: %s", baseDir)
|
||||
return nil // we return nil here because if the configuration directory doesnt exist, then the config file also doesnt exist.
|
||||
}
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
c.LogError("Failed to read configuration file: %v", err)
|
||||
return err // we return the error here because if the configuration directory exists, then the config file should also exist and not error.
|
||||
}
|
||||
|
||||
c.LogDebug("Configuration file loaded successfully")
|
||||
c.Config.JsonExists = true
|
||||
|
||||
if viper.Get("instances") != nil {
|
||||
instances := make([]coolTypes.Instance, 0)
|
||||
if err := viper.UnmarshalKey("instances", &instances); err != nil {
|
||||
c.LogError("Failed to unmarshal instances: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// if fqdn and token are not set, then we will set them to the default instance or name if provided from flags
|
||||
if c.Config.FQDN == "" && c.Config.Token == "" {
|
||||
c.LogDebug("FQDN and Token not provided via flags, looking for instance: %s", instanceName)
|
||||
for _, instance := range instances {
|
||||
if (instanceName != "" && instance.Name == instanceName) || (instance.Default && instanceName == "") {
|
||||
c.LogDebug("Using instance: %s with FQDN: %s", instance.Name, instance.Fqdn)
|
||||
c.Config.FQDN = instance.Fqdn
|
||||
c.Config.Token = instance.Token
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.ConfigureClient()
|
||||
}
|
||||
|
||||
// Save saves the configuration file
|
||||
func (c *Coolify) Save() error {
|
||||
baseDir := path.Join(c.Config.Directory, "coolify")
|
||||
c.LogDebug("Saving configuration to: %s", baseDir)
|
||||
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
c.LogDebug("Creating configuration directory: %s", baseDir)
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
c.LogError("Failed to create configuration directory: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
if c.Config.JsonExists {
|
||||
c.LogDebug("Updating existing configuration file")
|
||||
err = viper.WriteConfig()
|
||||
} else {
|
||||
c.LogDebug("Creating new configuration file")
|
||||
err = viper.SafeWriteConfig()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
c.LogError("Failed to save configuration: %v", err)
|
||||
} else {
|
||||
c.LogDebug("Configuration saved successfully")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete removes the configuration directory
|
||||
func (c *Coolify) Delete() error {
|
||||
configPath := path.Join(c.Config.Directory, "coolify")
|
||||
c.LogDebug("Deleting configuration directory: %s", configPath)
|
||||
|
||||
err := os.RemoveAll(configPath)
|
||||
if err != nil {
|
||||
c.LogError("Failed to delete configuration directory: %v", err)
|
||||
} else {
|
||||
c.LogDebug("Configuration directory deleted successfully")
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level for the logger
|
||||
func (c *Coolify) SetLogLevel(level string) {
|
||||
switch level {
|
||||
case "trace":
|
||||
c.Logger.SetLevel(logrus.TraceLevel)
|
||||
case "debug":
|
||||
c.Logger.SetLevel(logrus.DebugLevel)
|
||||
case "info":
|
||||
c.Logger.SetLevel(logrus.InfoLevel)
|
||||
case "warn", "warning":
|
||||
c.Logger.SetLevel(logrus.WarnLevel)
|
||||
case "error":
|
||||
c.Logger.SetLevel(logrus.ErrorLevel)
|
||||
case "fatal":
|
||||
c.Logger.SetLevel(logrus.FatalLevel)
|
||||
case "panic":
|
||||
c.Logger.SetLevel(logrus.PanicLevel)
|
||||
default:
|
||||
c.Logger.SetLevel(logrus.InfoLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// LogDebug logs a message at debug level
|
||||
func (c *Coolify) LogDebug(format string, args ...interface{}) {
|
||||
c.Logger.Debugf(format, args...)
|
||||
}
|
||||
|
||||
// LogInfo logs a message at info level
|
||||
func (c *Coolify) LogInfo(format string, args ...interface{}) {
|
||||
c.Logger.Infof(format, args...)
|
||||
}
|
||||
|
||||
// LogWarn logs a message at warn level
|
||||
func (c *Coolify) LogWarn(format string, args ...interface{}) {
|
||||
c.Logger.Warnf(format, args...)
|
||||
}
|
||||
|
||||
// LogError logs a message at error level
|
||||
func (c *Coolify) LogError(format string, args ...interface{}) {
|
||||
c.Logger.Errorf(format, args...)
|
||||
}
|
||||
|
||||
// LogTrace logs a message at trace level
|
||||
func (c *Coolify) LogTrace(format string, args ...interface{}) {
|
||||
c.Logger.Tracef(format, args...)
|
||||
}
|
||||
-247
@@ -1,247 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var WithResources bool
|
||||
|
||||
var serversCmd = &cobra.Command{
|
||||
Use: "server",
|
||||
Aliases: []string{"servers"},
|
||||
Short: "Server related commands",
|
||||
Long: `Manage Coolify servers - list, get details, add new servers, validate connections, and remove servers.`,
|
||||
}
|
||||
|
||||
var listServersCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all servers",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Check API version
|
||||
version, err := client.GetVersion(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API version: %w", err)
|
||||
}
|
||||
Version = version
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
servers, err := serverSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list servers: %w", err)
|
||||
}
|
||||
|
||||
// Use output formatter
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := formatter.Format(servers); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var oneServerCmd = &cobra.Command{
|
||||
Use: "get [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Get server details by uuid",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
// Get format flags
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
var data interface{}
|
||||
if WithResources {
|
||||
resources, err := serverSvc.GetResources(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server resources: %w", err)
|
||||
}
|
||||
data = resources.Resources
|
||||
} else {
|
||||
server, err := serverSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server: %w", err)
|
||||
}
|
||||
data = server
|
||||
}
|
||||
|
||||
// Use output formatter
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := formatter.Format(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable && !WithResources {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var removeServerCmd = &cobra.Command{
|
||||
Use: "remove [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Remove a server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
if err := serverSvc.Delete(ctx, uuid); err != nil {
|
||||
return fmt.Errorf("failed to delete server: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Server %s deleted successfully\n", uuid)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var addServerCmd = &cobra.Command{
|
||||
Use: "add [name] [ip] [private_key_uuid]",
|
||||
Args: exactArgs(3, "<uuid1> <uuid2> <uuid3>"),
|
||||
Short: "Add a server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Parse arguments and flags
|
||||
name := args[0]
|
||||
ip := args[1]
|
||||
privateKeyUuid := args[2]
|
||||
port, _ := cmd.Flags().GetInt("port")
|
||||
user, _ := cmd.Flags().GetString("user")
|
||||
validate, _ := cmd.Flags().GetBool("validate")
|
||||
|
||||
// Create request
|
||||
req := models.ServerCreateRequest{
|
||||
Name: name,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
User: user,
|
||||
PrivateKeyUUID: privateKeyUuid,
|
||||
InstantValidate: validate,
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
response, err := serverSvc.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
if validate {
|
||||
fmt.Printf("Server added successfully with uuid %s\n", response.UUID)
|
||||
} else {
|
||||
fmt.Printf("Server added successfully with uuid %s. Server is not validated. Use 'servers validate %s' to validate the server.\n", response.UUID, response.UUID)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var validateServerCmd = &cobra.Command{
|
||||
Use: "validate [uuid]",
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
Short: "Validate a server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Get API client
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
// Use service layer
|
||||
serverSvc := service.NewServerService(client)
|
||||
uuid := args[0]
|
||||
|
||||
response, err := serverSvc.Validate(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to validate server: %w", err)
|
||||
}
|
||||
|
||||
if response.Message != "" {
|
||||
fmt.Println(response.Message)
|
||||
} else {
|
||||
fmt.Printf("Server %s validated successfully\n", uuid)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Note: format and show-sensitive flags are inherited from rootCmd.PersistentFlags()
|
||||
|
||||
oneServerCmd.Flags().BoolVarP(&WithResources, "resources", "", false, "With resources")
|
||||
rootCmd.AddCommand(serversCmd)
|
||||
serversCmd.AddCommand(listServersCmd)
|
||||
serversCmd.AddCommand(oneServerCmd)
|
||||
|
||||
addServerCmd.Flags().IntP("port", "p", 22, "Port")
|
||||
addServerCmd.Flags().StringP("user", "u", "root", "User")
|
||||
addServerCmd.Flags().BoolP("validate", "", false, "Validate the server")
|
||||
serversCmd.AddCommand(addServerCmd)
|
||||
serversCmd.AddCommand(validateServerCmd)
|
||||
serversCmd.AddCommand(removeServerCmd)
|
||||
}
|
||||
-629
@@ -1,629 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/parser"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var servicesCmd = &cobra.Command{
|
||||
Use: "service",
|
||||
Aliases: []string{"services", "svc"},
|
||||
Short: "Service related commands",
|
||||
Long: `Manage Coolify one-click services (databases, Redis, PostgreSQL, etc.).`,
|
||||
}
|
||||
|
||||
var listServicesCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all services",
|
||||
Long: `List all services in your Coolify instance.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
services, err := serviceSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(services)
|
||||
},
|
||||
}
|
||||
|
||||
var getServiceCmd = &cobra.Command{
|
||||
Use: "get <uuid>",
|
||||
Short: "Get service details",
|
||||
Long: `Get detailed information about a specific service.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
svc, err := serviceSvc.Get(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(svc)
|
||||
},
|
||||
}
|
||||
|
||||
var startServiceCmd = &cobra.Command{
|
||||
Use: "start <uuid>",
|
||||
Short: "Start a service",
|
||||
Long: `Start a service (deploy all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Start(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var stopServiceCmd = &cobra.Command{
|
||||
Use: "stop <uuid>",
|
||||
Short: "Stop a service",
|
||||
Long: `Stop a service (stop all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Stop(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to stop service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var restartServiceCmd = &cobra.Command{
|
||||
Use: "restart <uuid>",
|
||||
Short: "Restart a service",
|
||||
Long: `Restart a service (restart all containers).`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
resp, err := serviceSvc.Restart(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println(resp.Message)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteServiceCmd = &cobra.Command{
|
||||
Use: "delete <uuid>",
|
||||
Short: "Delete a service",
|
||||
Long: `Delete a service and optionally clean up its configurations, volumes, and networks.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
deleteConfigurations, _ := cmd.Flags().GetBool("delete-configurations")
|
||||
deleteVolumes, _ := cmd.Flags().GetBool("delete-volumes")
|
||||
dockerCleanup, _ := cmd.Flags().GetBool("docker-cleanup")
|
||||
deleteConnectedNetworks, _ := cmd.Flags().GetBool("delete-connected-networks")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this service? (yes/no): ")
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
err = serviceSvc.Delete(ctx, uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete service: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Service deletion request queued.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var envsServiceCmd = &cobra.Command{
|
||||
Use: "env",
|
||||
Aliases: []string{"envs", "environment"},
|
||||
Short: "Manage service environment variables",
|
||||
Long: `Manage environment variables for a service. All commands require the service UUID first to establish context.`,
|
||||
}
|
||||
|
||||
var listServiceEnvsCmd = &cobra.Command{
|
||||
Use: "list <service_uuid>",
|
||||
Short: "List all environment variables for a service",
|
||||
Long: `List all environment variables for a specific service.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
envs, err := serviceSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list environment variables: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive values unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
for i := range envs {
|
||||
envs[i].Value = "********"
|
||||
if envs[i].RealValue != nil {
|
||||
masked := "********"
|
||||
envs[i].RealValue = &masked
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(envs)
|
||||
},
|
||||
}
|
||||
|
||||
var getServiceEnvCmd = &cobra.Command{
|
||||
Use: "get <service_uuid> <env_uuid_or_key>",
|
||||
Short: "Get environment variable details",
|
||||
Long: `Get detailed information about a specific environment variable. First UUID is the service, second is the environment variable UUID or key name.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.GetEnv(ctx, serviceUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get environment variable: %w", err)
|
||||
}
|
||||
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
// Mask sensitive value unless --show-sensitive is used
|
||||
if !showSensitive {
|
||||
env.Value = "********"
|
||||
if env.RealValue != nil {
|
||||
masked := "********"
|
||||
env.RealValue = &masked
|
||||
}
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter(Format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(env)
|
||||
},
|
||||
}
|
||||
|
||||
var createServiceEnvCmd = &cobra.Command{
|
||||
Use: "create <service_uuid>",
|
||||
Short: "Create an environment variable for a service",
|
||||
Long: `Create a new environment variable for a specific service. Use --key and --value flags to specify the variable.`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
|
||||
if key == "" {
|
||||
return fmt.Errorf("--key is required")
|
||||
}
|
||||
if value == "" {
|
||||
return fmt.Errorf("--value is required")
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
// Only set flags if they were explicitly provided
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.CreateEnv(ctx, uuid, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var updateServiceEnvCmd = &cobra.Command{
|
||||
Use: "update <service_uuid> <env_uuid>",
|
||||
Short: "Update an environment variable",
|
||||
Long: `Update an existing environment variable. First UUID is the service, second is the specific environment variable to update.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: envUUID,
|
||||
}
|
||||
|
||||
// Only set fields that were provided
|
||||
if cmd.Flags().Changed("key") {
|
||||
key, _ := cmd.Flags().GetString("key")
|
||||
req.Key = &key
|
||||
}
|
||||
if cmd.Flags().Changed("value") {
|
||||
value, _ := cmd.Flags().GetString("value")
|
||||
req.Value = &value
|
||||
}
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
if cmd.Flags().Changed("is-multiline") {
|
||||
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
|
||||
req.IsMultiline = &isMultiline
|
||||
}
|
||||
|
||||
// Check if at least one field is being updated
|
||||
if req.Key == nil && req.Value == nil && req.IsBuildTime == nil && req.IsPreview == nil && req.IsLiteral == nil && req.IsMultiline == nil {
|
||||
return fmt.Errorf("at least one field must be provided to update (--key, --value, --build-time, --preview, --is-literal, or --is-multiline)")
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
env, err := serviceSvc.UpdateEnv(ctx, serviceUUID, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var deleteServiceEnvCmd = &cobra.Command{
|
||||
Use: "delete <service_uuid> <env_uuid>",
|
||||
Short: "Delete an environment variable",
|
||||
Long: `Delete an environment variable from a service. First UUID is the service, second is the specific environment variable to delete.`,
|
||||
Args: exactArgs(2, "<uuid1> <uuid2>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
serviceUUID := args[0]
|
||||
envUUID := args[1]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
force, _ := cmd.Flags().GetBool("force")
|
||||
|
||||
// Prompt for confirmation unless --force is used
|
||||
if !force {
|
||||
var response string
|
||||
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
|
||||
fmt.Scanln(&response)
|
||||
|
||||
if response != "yes" && response != "y" {
|
||||
fmt.Println("Delete cancelled.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
err = serviceSvc.DeleteEnv(ctx, serviceUUID, envUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Environment variable deleted successfully.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var syncServiceEnvCmd = &cobra.Command{
|
||||
Use: "sync <service_uuid>",
|
||||
Short: "Sync environment variables from a .env file",
|
||||
Long: `Sync environment variables from a .env file. This command intelligently:
|
||||
- Updates existing environment variables with new values
|
||||
- Creates new environment variables that don't exist yet
|
||||
- Uses efficient bulk operations where possible
|
||||
|
||||
Example: coolify service env sync abc123 --file .env.production`,
|
||||
Args: exactArgs(1, "<uuid>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
uuid := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
filePath, _ := cmd.Flags().GetString("file")
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--file is required")
|
||||
}
|
||||
|
||||
isBuildTime, _ := cmd.Flags().GetBool("build-time")
|
||||
isPreview, _ := cmd.Flags().GetBool("preview")
|
||||
isLiteral, _ := cmd.Flags().GetBool("is-literal")
|
||||
|
||||
// Parse the .env file
|
||||
envVars, err := parser.ParseEnvFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse .env file: %w", err)
|
||||
}
|
||||
|
||||
if len(envVars) == 0 {
|
||||
fmt.Println("No environment variables found in file.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
|
||||
|
||||
// Fetch existing environment variables
|
||||
serviceSvc := service.NewServiceService(client)
|
||||
existingEnvs, err := serviceSvc.ListEnvs(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list existing environment variables: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of existing env vars by key
|
||||
existingMap := make(map[string]models.EnvironmentVariable)
|
||||
for _, env := range existingEnvs {
|
||||
existingMap[env.Key] = env
|
||||
}
|
||||
|
||||
// Separate into updates and creates
|
||||
var toUpdate []models.EnvironmentVariableCreateRequest
|
||||
var toCreate []models.EnvironmentVariableCreateRequest
|
||||
|
||||
for _, envVar := range envVars {
|
||||
req := models.EnvironmentVariableCreateRequest{
|
||||
Key: envVar.Key,
|
||||
Value: envVar.Value,
|
||||
}
|
||||
|
||||
// Apply flags if explicitly provided
|
||||
if cmd.Flags().Changed("build-time") {
|
||||
req.IsBuildTime = &isBuildTime
|
||||
}
|
||||
if cmd.Flags().Changed("preview") {
|
||||
req.IsPreview = &isPreview
|
||||
}
|
||||
if cmd.Flags().Changed("is-literal") {
|
||||
req.IsLiteral = &isLiteral
|
||||
}
|
||||
|
||||
// Auto-detect multiline values
|
||||
if strings.Contains(envVar.Value, "\n") {
|
||||
multiline := true
|
||||
req.IsMultiline = &multiline
|
||||
}
|
||||
|
||||
if _, exists := existingMap[envVar.Key]; exists {
|
||||
toUpdate = append(toUpdate, req)
|
||||
} else {
|
||||
toCreate = append(toCreate, req)
|
||||
}
|
||||
}
|
||||
|
||||
updateCount := 0
|
||||
createCount := 0
|
||||
failCount := 0
|
||||
|
||||
// Perform bulk update if there are vars to update
|
||||
if len(toUpdate) > 0 {
|
||||
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
|
||||
bulkReq := &service.BulkUpdateEnvsRequest{
|
||||
Data: toUpdate,
|
||||
}
|
||||
_, err := serviceSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
|
||||
failCount += len(toUpdate)
|
||||
} else {
|
||||
updateCount = len(toUpdate)
|
||||
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new variables one by one
|
||||
if len(toCreate) > 0 {
|
||||
fmt.Printf("Creating %d new variables...\n", len(toCreate))
|
||||
for _, req := range toCreate {
|
||||
_, err := serviceSvc.CreateEnv(ctx, uuid, &req)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
|
||||
failCount++
|
||||
} else {
|
||||
fmt.Printf(" ✓ Created '%s'\n", req.Key)
|
||||
createCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
|
||||
|
||||
if failCount > 0 {
|
||||
return fmt.Errorf("some environment variables failed to sync")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Define delete command flags
|
||||
deleteServiceCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
|
||||
deleteServiceCmd.Flags().Bool("delete-configurations", true, "Delete configurations")
|
||||
deleteServiceCmd.Flags().Bool("delete-volumes", true, "Delete volumes")
|
||||
deleteServiceCmd.Flags().Bool("docker-cleanup", true, "Run docker cleanup")
|
||||
deleteServiceCmd.Flags().Bool("delete-connected-networks", true, "Delete connected networks")
|
||||
|
||||
// Define envs create command flags
|
||||
createServiceEnvCmd.Flags().String("key", "", "Environment variable key (required)")
|
||||
createServiceEnvCmd.Flags().String("value", "", "Environment variable value (required)")
|
||||
createServiceEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
createServiceEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
createServiceEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
createServiceEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs update command flags
|
||||
updateServiceEnvCmd.Flags().String("key", "", "New environment variable key")
|
||||
updateServiceEnvCmd.Flags().String("value", "", "New environment variable value")
|
||||
updateServiceEnvCmd.Flags().Bool("build-time", false, "Available at build time")
|
||||
updateServiceEnvCmd.Flags().Bool("preview", false, "Available in preview deployments")
|
||||
updateServiceEnvCmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
|
||||
updateServiceEnvCmd.Flags().Bool("is-multiline", false, "Value is multiline")
|
||||
|
||||
// Define envs delete command flags
|
||||
deleteServiceEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
||||
|
||||
// Define envs sync command flags
|
||||
syncServiceEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
|
||||
syncServiceEnvCmd.Flags().Bool("build-time", false, "Make all variables available at build time")
|
||||
syncServiceEnvCmd.Flags().Bool("preview", false, "Make all variables available in preview deployments")
|
||||
syncServiceEnvCmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
|
||||
|
||||
rootCmd.AddCommand(servicesCmd)
|
||||
servicesCmd.AddCommand(listServicesCmd)
|
||||
servicesCmd.AddCommand(getServiceCmd)
|
||||
servicesCmd.AddCommand(startServiceCmd)
|
||||
servicesCmd.AddCommand(stopServiceCmd)
|
||||
servicesCmd.AddCommand(restartServiceCmd)
|
||||
servicesCmd.AddCommand(deleteServiceCmd)
|
||||
servicesCmd.AddCommand(envsServiceCmd)
|
||||
envsServiceCmd.AddCommand(listServiceEnvsCmd)
|
||||
envsServiceCmd.AddCommand(getServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(createServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(updateServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(deleteServiceEnvCmd)
|
||||
envsServiceCmd.AddCommand(syncServiceEnvCmd)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestServicesCmd_Structure(t *testing.T) {
|
||||
cmd := servicesCmd
|
||||
|
||||
assert.Equal(t, "services", cmd.Use)
|
||||
assert.NotEmpty(t, cmd.Short)
|
||||
|
||||
// Check that subcommands are registered
|
||||
hasListCmd := false
|
||||
hasGetCmd := false
|
||||
hasStartCmd := false
|
||||
hasStopCmd := false
|
||||
hasRestartCmd := false
|
||||
|
||||
for _, subCmd := range cmd.Commands() {
|
||||
switch subCmd.Use {
|
||||
case "list":
|
||||
hasListCmd = true
|
||||
case "get <uuid>":
|
||||
hasGetCmd = true
|
||||
case "start <uuid>":
|
||||
hasStartCmd = true
|
||||
case "stop <uuid>":
|
||||
hasStopCmd = true
|
||||
case "restart <uuid>":
|
||||
hasRestartCmd = true
|
||||
}
|
||||
}
|
||||
|
||||
assert.True(t, hasListCmd, "list subcommand should be registered")
|
||||
assert.True(t, hasGetCmd, "get subcommand should be registered")
|
||||
assert.True(t, hasStartCmd, "start subcommand should be registered")
|
||||
assert.True(t, hasStopCmd, "stop subcommand should be registered")
|
||||
assert.True(t, hasRestartCmd, "restart subcommand should be registered")
|
||||
}
|
||||
|
||||
func TestServicesListCmd_Structure(t *testing.T) {
|
||||
cmd := listServicesCmd
|
||||
|
||||
assert.Equal(t, "list", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.Nil(t, cmd.Args) // list takes no arguments
|
||||
}
|
||||
|
||||
func TestServicesGetCmd_Args(t *testing.T) {
|
||||
cmd := getServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesGetCmd_Structure(t *testing.T) {
|
||||
cmd := getServiceCmd
|
||||
|
||||
assert.Equal(t, "get <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesStartCmd_Args(t *testing.T) {
|
||||
cmd := startServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesStartCmd_Structure(t *testing.T) {
|
||||
cmd := startServiceCmd
|
||||
|
||||
assert.Equal(t, "start <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesStopCmd_Args(t *testing.T) {
|
||||
cmd := stopServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesStopCmd_Structure(t *testing.T) {
|
||||
cmd := stopServiceCmd
|
||||
|
||||
assert.Equal(t, "stop <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesRestartCmd_Args(t *testing.T) {
|
||||
cmd := restartServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesRestartCmd_Structure(t *testing.T) {
|
||||
cmd := restartServiceCmd
|
||||
|
||||
assert.Equal(t, "restart <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
}
|
||||
|
||||
func TestServicesDeleteCmd_Args(t *testing.T) {
|
||||
cmd := deleteServiceCmd
|
||||
|
||||
// Test with no arguments - should fail
|
||||
err := cmd.Args(cmd, []string{})
|
||||
assert.Error(t, err, "should require exactly 1 argument")
|
||||
|
||||
// Test with correct number of arguments - should pass
|
||||
err = cmd.Args(cmd, []string{"service-uuid-123"})
|
||||
assert.NoError(t, err, "should accept 1 argument")
|
||||
|
||||
// Test with too many arguments - should fail
|
||||
err = cmd.Args(cmd, []string{"uuid1", "uuid2"})
|
||||
assert.Error(t, err, "should not accept more than 1 argument")
|
||||
}
|
||||
|
||||
func TestServicesDeleteCmd_Structure(t *testing.T) {
|
||||
cmd := deleteServiceCmd
|
||||
|
||||
assert.Equal(t, "delete <uuid>", cmd.Use)
|
||||
assert.NotNil(t, cmd.RunE)
|
||||
assert.NotNil(t, cmd.Args)
|
||||
|
||||
// Verify flags exist
|
||||
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-configurations"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-volumes"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("docker-cleanup"))
|
||||
assert.NotNil(t, cmd.Flags().Lookup("delete-connected-networks"))
|
||||
}
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/output"
|
||||
"github.com/coollabsio/coolify-cli/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var teamsCmd = &cobra.Command{
|
||||
Use: "team",
|
||||
Aliases: []string{"teams"},
|
||||
Short: "Team related commands",
|
||||
Long: `Manage Coolify teams - list all teams, get team details, view current team, and manage team members.`,
|
||||
}
|
||||
|
||||
var listTeamsCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all teams",
|
||||
Long: `List all teams you have access to.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
teams, err := teamSvc.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list teams: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(teams)
|
||||
},
|
||||
}
|
||||
|
||||
var getTeamCmd = &cobra.Command{
|
||||
Use: "get <id>",
|
||||
Short: "Get team details by ID",
|
||||
Long: `Get detailed information about a specific team by its ID.`,
|
||||
Args: exactArgs(1, "<id>"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
teamID := args[0]
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
team, err := teamSvc.Get(ctx, teamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get team: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(team)
|
||||
},
|
||||
}
|
||||
|
||||
var currentTeamCmd = &cobra.Command{
|
||||
Use: "current",
|
||||
Short: "Get currently authenticated team",
|
||||
Long: `Get details of the team associated with the current authentication token.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
team, err := teamSvc.Current(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current team: %w", err)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
formatter, err := output.NewFormatter(format, output.Options{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
return formatter.Format(team)
|
||||
},
|
||||
}
|
||||
|
||||
var teamMembersCmd = &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "Team members related commands",
|
||||
Long: `Manage team members - list members of a specific team or the current team.`,
|
||||
}
|
||||
|
||||
var listTeamMembersCmd = &cobra.Command{
|
||||
Use: "list [team_id]",
|
||||
Short: "List team members",
|
||||
Long: `List members of a specific team by ID, or list members of the current team if no ID is provided.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
client, err := getAPIClient(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API client: %w", err)
|
||||
}
|
||||
|
||||
teamSvc := service.NewTeamService(client)
|
||||
|
||||
// If team ID provided, get members of that team
|
||||
// Otherwise get members of current team
|
||||
var members interface{}
|
||||
var membersErr error
|
||||
|
||||
if len(args) > 0 {
|
||||
teamID := args[0]
|
||||
members, membersErr = teamSvc.ListMembers(ctx, teamID)
|
||||
} else {
|
||||
members, membersErr = teamSvc.CurrentMembers(ctx)
|
||||
}
|
||||
|
||||
if membersErr != nil {
|
||||
return fmt.Errorf("failed to list team members: %w", membersErr)
|
||||
}
|
||||
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
if err := formatter.Format(members); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !showSensitive && format == output.FormatTable {
|
||||
fmt.Println("\nNote: Use -s to show sensitive information.")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(teamsCmd)
|
||||
teamsCmd.AddCommand(listTeamsCmd)
|
||||
teamsCmd.AddCommand(getTeamCmd)
|
||||
teamsCmd.AddCommand(currentTeamCmd)
|
||||
teamsCmd.AddCommand(teamMembersCmd)
|
||||
teamMembersCmd.AddCommand(listTeamMembersCmd)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
selfupdate "github.com/creativeprojects/go-selfupdate"
|
||||
compareVersion "github.com/hashicorp/go-version"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Update Coolify CLI",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
latest, found, err := selfupdate.DetectLatest(context.Background(), selfupdate.ParseSlug("coollabsio/coolify-cli"))
|
||||
if err != nil {
|
||||
log.Printf("Error occurred while detecting version: %v", err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
log.Printf("Latest version for %s/%s could not be found from github repository", runtime.GOOS, runtime.GOARCH)
|
||||
return
|
||||
}
|
||||
currentVersion, err := compareVersion.NewVersion(CliVersion)
|
||||
if err != nil {
|
||||
log.Printf("Could not parse current version: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
latestVersion, err := compareVersion.NewVersion(latest.Version())
|
||||
if err != nil {
|
||||
log.Printf("Could not parse latest version: %v", err)
|
||||
return
|
||||
}
|
||||
if currentVersion.LessThan(latestVersion) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Printf("Could not locate executable path: %v", err)
|
||||
return
|
||||
}
|
||||
if err := selfupdate.UpdateTo(context.Background(), latest.AssetURL, latest.AssetName, exe); err != nil {
|
||||
fmt.Printf("Error occurred while updating binary: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Successfully updated to version %s", latest.Version())
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(updateCmd)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetCommandExample generates example usage strings using the actual binary name
|
||||
// rather than hardcoding it. This makes examples resistant to binary name changes.
|
||||
func GetCommandExample(format string, args ...interface{}) string {
|
||||
binaryName := getBinaryName()
|
||||
return fmt.Sprintf(format, append([]interface{}{binaryName}, args...)...)
|
||||
}
|
||||
|
||||
// getBinaryName returns the name of the current executable without path
|
||||
func getBinaryName() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "coolify-cli" // Fallback to the default name
|
||||
}
|
||||
return filepath.Base(exe)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package utils
|
||||
|
||||
// Other utility functions can be added here
|
||||
@@ -1,19 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Current Coolify CLI version",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(CliVersion)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔧 Setting up Coolify CLI workspace..."
|
||||
|
||||
# Check if Go is installed
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "❌ Error: Go is not installed"
|
||||
echo "Please install Go 1.24+ from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check Go version
|
||||
GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//')
|
||||
MAJOR_MINOR=$(echo $GO_VERSION | cut -d. -f1,2)
|
||||
|
||||
# Compare version (must be 1.24 or higher)
|
||||
if [ $(echo "$MAJOR_MINOR" | awk -F. '{print ($1 * 100) + $2}') -lt 124 ]; then
|
||||
echo "❌ Error: Go version 1.24+ is required"
|
||||
echo "Current version: $GO_VERSION"
|
||||
echo "Please upgrade Go from https://go.dev/dl/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Go version $GO_VERSION detected"
|
||||
|
||||
# Download dependencies
|
||||
echo "📦 Downloading dependencies..."
|
||||
if ! go mod download; then
|
||||
echo "❌ Error: Failed to download dependencies"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Dependencies downloaded"
|
||||
|
||||
# Install air if not already installed
|
||||
if ! command -v air &> /dev/null; then
|
||||
echo "📦 Installing air (Go file watcher)..."
|
||||
if ! go install github.com/air-verse/air@latest; then
|
||||
echo "⚠️ Warning: Failed to install air, but continuing..."
|
||||
else
|
||||
echo "✅ air installed successfully"
|
||||
fi
|
||||
else
|
||||
echo "✅ air already installed"
|
||||
fi
|
||||
|
||||
# Build the binary
|
||||
echo "🔨 Building coolify binary..."
|
||||
if ! go build -o coolify .; then
|
||||
echo "❌ Error: Build failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Binary built successfully: ./coolify"
|
||||
echo "🎉 Workspace setup complete!"
|
||||
echo "🔥 Use the run script for hot reload during development"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "./conductor-setup.sh",
|
||||
"run": "~/go/bin/air"
|
||||
},
|
||||
"runScriptMode": "nonconcurrent"
|
||||
}
|
||||
@@ -1,47 +1,73 @@
|
||||
module github.com/coollabsio/coolify-cli
|
||||
module github.com/coollabsio/cli-coolify
|
||||
|
||||
go 1.24.6
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbletea v1.3.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/google/go-github/v71 v71.0.0
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/oapi-codegen/runtime v1.1.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.20.1
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.29.0
|
||||
golang.org/x/sys v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.22.0 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.3.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||
github.com/getkin/kin-openapi v0.127.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
|
||||
github.com/spf13/afero v1.14.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||
github.com/xanzy/go-gitlab v0.115.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
||||
|
||||
@@ -1,118 +1,276 @@
|
||||
code.gitea.io/sdk/gitea v0.22.0 h1:HCKq7bX/HQ85Nw7c/HAhWgRye+vBp5nQOE8Md1+9Ef0=
|
||||
code.gitea.io/sdk/gitea v0.22.0/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
|
||||
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
|
||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
|
||||
github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
|
||||
github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1 h1:fuyEGFFfqcC8SxDGolcEPYPLXGQ9Mcrc5uRyRG2Mqnk=
|
||||
github.com/creativeprojects/go-selfupdate v1.5.1/go.mod h1:2uY75rP8z/D/PBuDn6mlBnzu+ysEmwOJfcgF8np0JIM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w=
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/getkin/kin-openapi v0.127.0 h1:Mghqi3Dhryf3F8vR370nN67pAERW+3a95vomb3MAREY=
|
||||
github.com/getkin/kin-openapi v0.127.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
|
||||
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30=
|
||||
github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1 h1:ykgG34472DWey7TSjd8vIfNykXgjOgYJZoQbKfEeY/Q=
|
||||
github.com/oapi-codegen/oapi-codegen/v2 v2.4.1/go.mod h1:N5+lY1tiTDV3V1BeHtOxeWXHoPVeApvsvjJqegfoaz8=
|
||||
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
|
||||
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
|
||||
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
|
||||
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/xanzy/go-gitlab v0.115.0 h1:6DmtItNcVe+At/liXSgfE/DZNZrGfalQmBRmOcJjOn8=
|
||||
github.com/xanzy/go-gitlab v0.115.0/go.mod h1:5XCDtM7AM6WMKmfDdOiEpyRWUqui2iS9ILfvCZ2gJ5M=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 30 * time.Second
|
||||
defaultRetries = 3
|
||||
apiV1Path = "/api/v1/"
|
||||
)
|
||||
|
||||
// Client is the HTTP client for Coolify API
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
debug bool
|
||||
retries int
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// NewClient creates a new API client
|
||||
func NewClient(baseURL, token string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: baseURL,
|
||||
token: token,
|
||||
httpClient: &http.Client{},
|
||||
timeout: defaultTimeout,
|
||||
retries: defaultRetries,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
// Apply options
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
|
||||
// Set timeout on HTTP client
|
||||
c.httpClient.Timeout = c.timeout
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// Get makes a GET request to the API
|
||||
func (c *Client) Get(ctx context.Context, path string, result interface{}) error {
|
||||
return c.doRequest(ctx, "GET", path, nil, result)
|
||||
}
|
||||
|
||||
// Post makes a POST request to the API
|
||||
func (c *Client) Post(ctx context.Context, path string, body, result interface{}) error {
|
||||
return c.doRequest(ctx, "POST", path, body, result)
|
||||
}
|
||||
|
||||
// Delete makes a DELETE request to the API
|
||||
func (c *Client) Delete(ctx context.Context, path string) error {
|
||||
return c.doRequest(ctx, "DELETE", path, nil, nil)
|
||||
}
|
||||
|
||||
// Patch makes a PATCH request to the API
|
||||
func (c *Client) Patch(ctx context.Context, path string, body, result interface{}) error {
|
||||
return c.doRequest(ctx, "PATCH", path, body, result)
|
||||
}
|
||||
|
||||
// GetVersion fetches the API version
|
||||
func (c *Client) GetVersion(ctx context.Context) (string, error) {
|
||||
var version string
|
||||
err := c.Get(ctx, "version", &version)
|
||||
return version, err
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request with retry logic
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body, result interface{}) error {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt <= c.retries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff
|
||||
backoff := time.Duration(math.Pow(2, float64(attempt-1))) * time.Second
|
||||
// Always log retries so users know what's happening
|
||||
log.Printf("Request failed, retrying (attempt %d/%d) after %v...", attempt, c.retries, backoff)
|
||||
select {
|
||||
case <-time.After(backoff):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
err := c.doRequestOnce(ctx, method, path, body, result)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// Don't retry on client errors (4xx) except 429 (rate limit)
|
||||
if apiErr, ok := err.(*Error); ok {
|
||||
if apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 && apiErr.StatusCode != 429 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Don't retry on context cancellation
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// doRequestOnce executes a single HTTP request
|
||||
func (c *Client) doRequestOnce(ctx context.Context, method, path string, body, result interface{}) error {
|
||||
url := c.baseURL + apiV1Path + path
|
||||
|
||||
if c.debug {
|
||||
log.Printf("%s %s", method, url)
|
||||
}
|
||||
|
||||
// Prepare request body
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(jsonBody)
|
||||
|
||||
if c.debug {
|
||||
log.Printf("Request body: %s", string(jsonBody))
|
||||
}
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, bodyReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set headers
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
// Execute request
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if c.debug {
|
||||
log.Printf("Response status: %d", resp.StatusCode)
|
||||
log.Printf("Response body: %s", string(respBody))
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
message := string(respBody)
|
||||
if message == "" {
|
||||
message = "Unknown error"
|
||||
} else {
|
||||
// Try to extract error message from JSON
|
||||
var errResp struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &errResp); err == nil {
|
||||
if errResp.Message != "" {
|
||||
message = errResp.Message
|
||||
} else if errResp.Error != "" {
|
||||
message = errResp.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NewError(resp.StatusCode, path, message)
|
||||
}
|
||||
|
||||
// Unmarshal response into result
|
||||
if result != nil {
|
||||
// Handle string responses
|
||||
if strResult, ok := result.(*string); ok {
|
||||
*strResult = string(respBody)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respBody, result); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
t.Run("creates client with defaults", func(t *testing.T) {
|
||||
client := NewClient("https://app.coolify.io", "test-token")
|
||||
|
||||
assert.Equal(t, "https://app.coolify.io", client.baseURL)
|
||||
assert.Equal(t, "test-token", client.token)
|
||||
assert.Equal(t, defaultTimeout, client.timeout)
|
||||
assert.Equal(t, defaultRetries, client.retries)
|
||||
assert.False(t, client.debug)
|
||||
})
|
||||
|
||||
t.Run("applies options", func(t *testing.T) {
|
||||
customTimeout := 10 * time.Second
|
||||
client := NewClient(
|
||||
"https://app.coolify.io",
|
||||
"test-token",
|
||||
WithDebug(true),
|
||||
WithTimeout(customTimeout),
|
||||
WithRetries(5),
|
||||
)
|
||||
|
||||
assert.True(t, client.debug)
|
||||
assert.Equal(t, customTimeout, client.timeout)
|
||||
assert.Equal(t, 5, client.retries)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_Get_Success(t *testing.T) {
|
||||
type ServerResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode([]ServerResponse{
|
||||
{UUID: "uuid-1", Name: "server-1"},
|
||||
{UUID: "uuid-2", Name: "server-2"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var servers []ServerResponse
|
||||
|
||||
err := client.Get(context.Background(), "servers", &servers)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, servers, 2)
|
||||
assert.Equal(t, "uuid-1", servers[0].UUID)
|
||||
assert.Equal(t, "server-1", servers[0].Name)
|
||||
}
|
||||
|
||||
func TestClient_Get_StringResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/version", r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("4.0.0"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var version string
|
||||
|
||||
err := client.Get(context.Background(), "version", &version)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4.0.0", version)
|
||||
}
|
||||
|
||||
func TestClient_Get_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Server not found",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "servers/unknown", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsNotFound(err))
|
||||
|
||||
var apiErr *Error
|
||||
require.ErrorAs(t, err, &apiErr)
|
||||
assert.Equal(t, 404, apiErr.StatusCode)
|
||||
assert.Equal(t, "Server not found", apiErr.Message)
|
||||
}
|
||||
|
||||
func TestClient_Get_Unauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Invalid token",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "invalid-token")
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "servers", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsUnauthorized(err))
|
||||
}
|
||||
|
||||
func TestClient_Post_Success(t *testing.T) {
|
||||
type CreateServerRequest struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type CreateServerResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
var req CreateServerRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test-server", req.Name)
|
||||
assert.Equal(t, "192.168.1.100", req.IP)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(CreateServerResponse{
|
||||
UUID: "new-uuid",
|
||||
Message: "Server created",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
requestBody := CreateServerRequest{
|
||||
Name: "test-server",
|
||||
IP: "192.168.1.100",
|
||||
}
|
||||
|
||||
var response CreateServerResponse
|
||||
err := client.Post(context.Background(), "servers", requestBody, &response)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "new-uuid", response.UUID)
|
||||
assert.Equal(t, "Server created", response.Message)
|
||||
}
|
||||
|
||||
func TestClient_Post_BadRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Invalid IP address",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
requestBody := map[string]string{"ip": "invalid"}
|
||||
var response interface{}
|
||||
|
||||
err := client.Post(context.Background(), "servers", requestBody, &response)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.True(t, IsBadRequest(err))
|
||||
}
|
||||
|
||||
func TestClient_Delete_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Server deleted",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
err := client.Delete(context.Background(), "servers/test-uuid")
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_GetVersion(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/version", r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("4.0.0-beta.383"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
|
||||
version, err := client.GetVersion(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4.0.0-beta.383", version)
|
||||
}
|
||||
|
||||
func TestClient_Retry_Success(t *testing.T) {
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("success"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithRetries(3))
|
||||
var result string
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "success", result)
|
||||
assert.Equal(t, 3, attempts)
|
||||
}
|
||||
|
||||
func TestClient_Retry_NoRetryOn4xx(t *testing.T) {
|
||||
attempts := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attempts++
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Bad request"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithRetries(3))
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, 1, attempts) // Should not retry on 400
|
||||
assert.True(t, IsBadRequest(err))
|
||||
}
|
||||
|
||||
func TestClient_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Cancel immediately
|
||||
cancel()
|
||||
|
||||
var result interface{}
|
||||
err := client.Get(ctx, "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, context.Canceled, err)
|
||||
}
|
||||
|
||||
func TestClient_Timeout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token", WithTimeout(100*time.Millisecond))
|
||||
var result interface{}
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "deadline exceeded")
|
||||
}
|
||||
|
||||
func TestClient_Debug(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("test"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// This test just verifies debug mode doesn't crash
|
||||
// In real usage, debug logs would go to stdout
|
||||
client := NewClient(server.URL, "test-token", WithDebug(true))
|
||||
var result string
|
||||
|
||||
err := client.Get(context.Background(), "test", &result)
|
||||
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Error represents an API error response
|
||||
type Error struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e *Error) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("API error %d on %s: %s", e.StatusCode, e.Path, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("API error %d on %s", e.StatusCode, e.Path)
|
||||
}
|
||||
|
||||
// NewError creates a new API error
|
||||
func NewError(statusCode int, path, message string) *Error {
|
||||
return &Error{
|
||||
StatusCode: statusCode,
|
||||
Path: path,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a 404 Not Found error
|
||||
func IsNotFound(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 404
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsUnauthorized checks if the error is a 401 or 403 error
|
||||
func IsUnauthorized(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 401 || apiErr.StatusCode == 403
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsBadRequest checks if the error is a 400 Bad Request error
|
||||
func IsBadRequest(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode == 400
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsServerError checks if the error is a 5xx server error
|
||||
func IsServerError(err error) bool {
|
||||
var apiErr *Error
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.StatusCode >= 500 && apiErr.StatusCode < 600
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Option configures the API client
|
||||
type Option func(*Client)
|
||||
|
||||
// WithDebug enables debug logging
|
||||
func WithDebug(debug bool) Option {
|
||||
return func(c *Client) {
|
||||
c.debug = debug
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the request timeout
|
||||
func WithTimeout(timeout time.Duration) Option {
|
||||
return func(c *Client) {
|
||||
c.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetries sets the number of retries for failed requests
|
||||
func WithRetries(retries int) Option {
|
||||
return func(c *Client) {
|
||||
c.retries = retries
|
||||
}
|
||||
}
|
||||
|
||||
// WithHTTPClient sets a custom HTTP client
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
c.httpClient = client
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
)
|
||||
|
||||
// Config holds all CLI configuration
|
||||
type Config struct {
|
||||
Instances []Instance `json:"instances"`
|
||||
LastUpdateCheckTime string `json:"lastUpdateCheckTime"`
|
||||
path string // config file path (not serialized)
|
||||
}
|
||||
|
||||
// New creates a new config with default values
|
||||
func New() *Config {
|
||||
return &Config{
|
||||
Instances: []Instance{},
|
||||
LastUpdateCheckTime: time.Now().Format(time.RFC3339),
|
||||
path: Path(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads config from the default location
|
||||
func Load() (*Config, error) {
|
||||
return LoadFromFile(Path())
|
||||
}
|
||||
|
||||
// Save saves config to the default location
|
||||
func (c *Config) Save() error {
|
||||
c.path = Path()
|
||||
return SaveToFile(c.path, c)
|
||||
}
|
||||
|
||||
// GetDefault returns the default instance
|
||||
func (c *Config) GetDefault() (*Instance, error) {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Default {
|
||||
return &c.Instances[i], nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("no default instance configured")
|
||||
}
|
||||
|
||||
// SetDefault sets the default instance by name
|
||||
func (c *Config) SetDefault(name string) error {
|
||||
found := false
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
c.Instances[i].Default = true
|
||||
found = true
|
||||
} else {
|
||||
c.Instances[i].Default = false
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddInstance adds a new instance to the configuration
|
||||
func (c *Config) AddInstance(instance Instance) error {
|
||||
// Validate instance
|
||||
if err := instance.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid instance: %w", err)
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == instance.Name {
|
||||
return fmt.Errorf("instance '%s' already exists", instance.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the first instance or marked as default, make it default
|
||||
if len(c.Instances) == 0 || instance.Default {
|
||||
// Clear other defaults
|
||||
for i := range c.Instances {
|
||||
c.Instances[i].Default = false
|
||||
}
|
||||
instance.Default = true
|
||||
}
|
||||
|
||||
c.Instances = append(c.Instances, instance)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveInstance removes an instance by name
|
||||
func (c *Config) RemoveInstance(name string) error {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
wasDefault := c.Instances[i].Default
|
||||
|
||||
// Remove instance
|
||||
c.Instances = append(c.Instances[:i], c.Instances[i+1:]...)
|
||||
|
||||
// If it was default, make the first instance default
|
||||
if wasDefault && len(c.Instances) > 0 {
|
||||
c.Instances[0].Default = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
// GetInstance gets an instance by name
|
||||
func (c *Config) GetInstance(name string) (*Instance, error) {
|
||||
for i := range c.Instances {
|
||||
if c.Instances[i].Name == name {
|
||||
return &c.Instances[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("instance '%s' not found", name)
|
||||
}
|
||||
|
||||
// UpdateInstanceToken updates the token for an instance
|
||||
func (c *Config) UpdateInstanceToken(name, token string) error {
|
||||
instance, err := c.GetInstance(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return errors.New("token cannot be empty")
|
||||
}
|
||||
|
||||
instance.Token = token
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListInstances returns all instances
|
||||
func (c *Config) ListInstances() []Instance {
|
||||
return c.Instances
|
||||
}
|
||||
|
||||
// Validate validates the entire config
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.Instances) == 0 {
|
||||
return errors.New("no instances configured")
|
||||
}
|
||||
|
||||
// Validate each instance
|
||||
for i, instance := range c.Instances {
|
||||
if err := instance.Validate(); err != nil {
|
||||
return fmt.Errorf("instance %d (%s) is invalid: %w", i, instance.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate names
|
||||
names := make(map[string]bool)
|
||||
for _, instance := range c.Instances {
|
||||
if names[instance.Name] {
|
||||
return fmt.Errorf("duplicate instance name: %s", instance.Name)
|
||||
}
|
||||
names[instance.Name] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Path returns the default config file path
|
||||
// Linux/macOS: ~/.config/coolify/config.json
|
||||
// Windows: %APPDATA%\coolify\config.json (e.g., C:\Users\username\AppData\Roaming\coolify\config.json)
|
||||
func Path() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
// Fallback to xdg if home dir fails
|
||||
return filepath.Join(xdg.ConfigHome, "coolify", "config.json")
|
||||
}
|
||||
|
||||
// Windows uses AppData/Roaming
|
||||
if filepath.Separator == '\\' {
|
||||
appData := os.Getenv("APPDATA")
|
||||
if appData != "" {
|
||||
return filepath.Join(appData, "coolify", "config.json")
|
||||
}
|
||||
// Fallback for Windows if APPDATA not set
|
||||
return filepath.Join(homeDir, "AppData", "Roaming", "coolify", "config.json")
|
||||
}
|
||||
|
||||
// Unix-like systems (Linux, macOS, BSD, etc.)
|
||||
return filepath.Join(homeDir, ".config", "coolify", "config.json")
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInstance_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instance Instance
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid instance",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid instance with http",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty name",
|
||||
instance: Instance{
|
||||
Name: "",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "name cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "empty FQDN",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "FQDN cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid FQDN (no protocol)",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "app.coolify.io",
|
||||
Token: "test-token",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "must start with http",
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
instance: Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "token cannot be empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.instance.Validate()
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.errMsg)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
assert.NotNil(t, cfg)
|
||||
assert.Empty(t, cfg.Instances)
|
||||
assert.NotEmpty(t, cfg.LastUpdateCheckTime)
|
||||
}
|
||||
|
||||
func TestConfig_AddInstance(t *testing.T) {
|
||||
t.Run("add first instance makes it default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
instance := Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "test-token",
|
||||
}
|
||||
|
||||
err := cfg.AddInstance(instance)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
})
|
||||
|
||||
t.Run("add second instance keeps first as default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 2)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
assert.False(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("add instance with default flag", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Instances[0].Default)
|
||||
assert.True(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("duplicate name returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token1",
|
||||
})
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://other.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
})
|
||||
|
||||
t.Run("invalid instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.AddInstance(Instance{
|
||||
Name: "",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid instance")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_RemoveInstance(t *testing.T) {
|
||||
t.Run("remove existing instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.RemoveInstance("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.Equal(t, "first", cfg.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("remove default instance makes first default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
cfg.SetDefault("second")
|
||||
|
||||
err := cfg.RemoveInstance("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
})
|
||||
|
||||
t.Run("remove non-existent instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.RemoveInstance("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_GetInstance(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
})
|
||||
|
||||
t.Run("get existing instance", func(t *testing.T) {
|
||||
instance, err := cfg.GetInstance("test")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", instance.Name)
|
||||
assert.Equal(t, "https://test.io", instance.FQDN)
|
||||
})
|
||||
|
||||
t.Run("get non-existent instance", func(t *testing.T) {
|
||||
_, err := cfg.GetInstance("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_GetDefault(t *testing.T) {
|
||||
t.Run("get default instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
instance, err := cfg.GetDefault()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test", instance.Name)
|
||||
})
|
||||
|
||||
t.Run("no default instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
_, err := cfg.GetDefault()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no default instance")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_SetDefault(t *testing.T) {
|
||||
t.Run("set existing instance as default", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "first",
|
||||
FQDN: "https://first.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "second",
|
||||
FQDN: "https://second.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.SetDefault("second")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cfg.Instances[0].Default)
|
||||
assert.True(t, cfg.Instances[1].Default)
|
||||
})
|
||||
|
||||
t.Run("set non-existent instance returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.SetDefault("nonexistent")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_UpdateInstanceToken(t *testing.T) {
|
||||
t.Run("update existing instance token", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "old-token",
|
||||
})
|
||||
|
||||
err := cfg.UpdateInstanceToken("test", "new-token")
|
||||
|
||||
require.NoError(t, err)
|
||||
instance, _ := cfg.GetInstance("test")
|
||||
assert.Equal(t, "new-token", instance.Token)
|
||||
})
|
||||
|
||||
t.Run("update non-existent instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.UpdateInstanceToken("nonexistent", "token")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("empty token returns error", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "old-token",
|
||||
})
|
||||
|
||||
err := cfg.UpdateInstanceToken("test", "")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be empty")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
t.Run("valid config", func(t *testing.T) {
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("empty instances", func(t *testing.T) {
|
||||
cfg := New()
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no instances")
|
||||
})
|
||||
|
||||
t.Run("invalid instance", func(t *testing.T) {
|
||||
cfg := New()
|
||||
// Bypass AddInstance validation
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "",
|
||||
FQDN: "https://test.io",
|
||||
Token: "token",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "is invalid")
|
||||
})
|
||||
|
||||
t.Run("duplicate names", func(t *testing.T) {
|
||||
cfg := New()
|
||||
// Bypass AddInstance validation
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test1.io",
|
||||
Token: "token1",
|
||||
})
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test2.io",
|
||||
Token: "token2",
|
||||
})
|
||||
|
||||
err := cfg.Validate()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "duplicate")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadFromFile(t *testing.T) {
|
||||
t.Run("load valid config", func(t *testing.T) {
|
||||
// Create temp file
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
validConfig := Config{
|
||||
Instances: []Instance{
|
||||
{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
LastUpdateCheckTime: "2025-10-14T12:00:00Z",
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(validConfig)
|
||||
os.WriteFile(configPath, data, 0600)
|
||||
|
||||
// Load config
|
||||
cfg, err := LoadFromFile(configPath)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, cfg.Instances, 1)
|
||||
assert.Equal(t, "test", cfg.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
_, err := LoadFromFile("/nonexistent/config.json")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not found")
|
||||
})
|
||||
|
||||
t.Run("invalid JSON", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
os.WriteFile(configPath, []byte("invalid json"), 0600)
|
||||
|
||||
_, err := LoadFromFile(configPath)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to parse")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSaveToFile(t *testing.T) {
|
||||
t.Run("save valid config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := New()
|
||||
cfg.AddInstance(Instance{
|
||||
Name: "test",
|
||||
FQDN: "https://test.io",
|
||||
Token: "test-token",
|
||||
})
|
||||
|
||||
err := SaveToFile(configPath, cfg)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file was created
|
||||
assert.FileExists(t, configPath)
|
||||
|
||||
// Verify content
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var loaded Config
|
||||
json.Unmarshal(data, &loaded)
|
||||
assert.Len(t, loaded.Instances, 1)
|
||||
assert.Equal(t, "test", loaded.Instances[0].Name)
|
||||
})
|
||||
|
||||
t.Run("nil config", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
err := SaveToFile(configPath, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be nil")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateDefault(t *testing.T) {
|
||||
// Test that CreateDefault sets up proper instances
|
||||
cfg := New()
|
||||
|
||||
// Simulate CreateDefault
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "cloud",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "localhost",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "root",
|
||||
})
|
||||
|
||||
// Verify instances
|
||||
assert.Len(t, cfg.Instances, 2)
|
||||
assert.Equal(t, "cloud", cfg.Instances[0].Name)
|
||||
assert.Equal(t, "localhost", cfg.Instances[1].Name)
|
||||
assert.Equal(t, "root", cfg.Instances[1].Token)
|
||||
assert.True(t, cfg.Instances[0].Default)
|
||||
assert.False(t, cfg.Instances[1].Default)
|
||||
}
|
||||
|
||||
func TestPath(t *testing.T) {
|
||||
path := Path()
|
||||
|
||||
// Should not be empty
|
||||
assert.NotEmpty(t, path)
|
||||
|
||||
// Should contain "coolify"
|
||||
assert.Contains(t, path, "coolify")
|
||||
|
||||
// Should end with config.json
|
||||
assert.Contains(t, path, "config.json")
|
||||
|
||||
// On Unix systems, should contain .config
|
||||
// On Windows, should contain AppData or Roaming
|
||||
if filepath.Separator == '/' {
|
||||
assert.Contains(t, path, ".config")
|
||||
} else {
|
||||
// Windows path should contain either AppData or backslashes
|
||||
assert.True(t,
|
||||
filepath.Separator == '\\' &&
|
||||
(os.Getenv("APPDATA") != "" || filepath.IsAbs(path)),
|
||||
"Windows path should be valid",
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf("Config path: %s", path)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Instance represents a Coolify instance configuration
|
||||
type Instance struct {
|
||||
Name string `json:"name"`
|
||||
FQDN string `json:"fqdn"`
|
||||
Token string `json:"token"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
}
|
||||
|
||||
// Validate validates the instance configuration
|
||||
func (i *Instance) Validate() error {
|
||||
if strings.TrimSpace(i.Name) == "" {
|
||||
return errors.New("instance name cannot be empty")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(i.FQDN) == "" {
|
||||
return errors.New("instance FQDN cannot be empty")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(i.FQDN, "http://") && !strings.HasPrefix(i.FQDN, "https://") {
|
||||
return fmt.Errorf("instance FQDN must start with http:// or https://")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(i.Token) == "" {
|
||||
return errors.New("instance token cannot be empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// LoadFromFile loads config from a specific file path
|
||||
func LoadFromFile(path string) (*Config, error) {
|
||||
// Check if file exists
|
||||
if !fileExists(path) {
|
||||
return nil, fmt.Errorf("config file not found: %s", path)
|
||||
}
|
||||
|
||||
// Read file
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal JSON
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
cfg.path = path
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveToFile saves config to a specific file path
|
||||
func SaveToFile(path string, cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return errors.New("config cannot be nil")
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write config file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists checks if the config file exists at the default location
|
||||
func Exists() bool {
|
||||
return fileExists(Path())
|
||||
}
|
||||
|
||||
// CreateDefault creates a default config file with cloud and localhost instances
|
||||
func CreateDefault() error {
|
||||
cfg := New()
|
||||
|
||||
// Add default cloud instance
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "cloud",
|
||||
FQDN: "https://app.coolify.io",
|
||||
Token: "",
|
||||
Default: true,
|
||||
})
|
||||
|
||||
// Add localhost instance
|
||||
cfg.Instances = append(cfg.Instances, Instance{
|
||||
Name: "localhost",
|
||||
FQDN: "http://localhost:8000",
|
||||
Token: "root",
|
||||
})
|
||||
|
||||
return cfg.Save()
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package models
|
||||
|
||||
// Application represents a Coolify application
|
||||
type Application struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// ApplicationListItem represents a simplified application for list view
|
||||
type ApplicationListItem struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
FQDN *string `json:"fqdn,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationUpdateRequest represents the request to update an application
|
||||
// All fields are optional - only provided fields will be updated
|
||||
type ApplicationUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
GitBranch *string `json:"git_branch,omitempty"`
|
||||
GitRepository *string `json:"git_repository,omitempty"`
|
||||
GitCommitSHA *string `json:"git_commit_sha,omitempty"`
|
||||
Domains *string `json:"domains,omitempty"`
|
||||
BuildCommand *string `json:"build_command,omitempty"`
|
||||
StartCommand *string `json:"start_command,omitempty"`
|
||||
InstallCommand *string `json:"install_command,omitempty"`
|
||||
BaseDirectory *string `json:"base_directory,omitempty"`
|
||||
PublishDirectory *string `json:"publish_directory,omitempty"`
|
||||
BuildPack *string `json:"build_pack,omitempty"`
|
||||
PortsExposes *string `json:"ports_exposes,omitempty"`
|
||||
PortsMappings *string `json:"ports_mappings,omitempty"`
|
||||
|
||||
// Docker configuration
|
||||
Dockerfile *string `json:"dockerfile,omitempty"`
|
||||
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty"`
|
||||
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty"`
|
||||
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
|
||||
CustomLabels *string `json:"custom_labels,omitempty"`
|
||||
|
||||
// Health checks
|
||||
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
|
||||
HealthCheckPath *string `json:"health_check_path,omitempty"`
|
||||
HealthCheckPort *string `json:"health_check_port,omitempty"`
|
||||
HealthCheckHost *string `json:"health_check_host,omitempty"`
|
||||
HealthCheckMethod *string `json:"health_check_method,omitempty"`
|
||||
HealthCheckScheme *string `json:"health_check_scheme,omitempty"`
|
||||
HealthCheckReturnCode *int `json:"health_check_return_code,omitempty"`
|
||||
HealthCheckResponseText *string `json:"health_check_response_text,omitempty"`
|
||||
HealthCheckInterval *int `json:"health_check_interval,omitempty"`
|
||||
HealthCheckTimeout *int `json:"health_check_timeout,omitempty"`
|
||||
HealthCheckRetries *int `json:"health_check_retries,omitempty"`
|
||||
HealthCheckStartPeriod *int `json:"health_check_start_period,omitempty"`
|
||||
|
||||
// Resource limits
|
||||
LimitsCPUs *string `json:"limits_cpus,omitempty"`
|
||||
LimitsCPUShares *int `json:"limits_cpu_shares,omitempty"`
|
||||
LimitsCPUSet *string `json:"limits_cpuset,omitempty"`
|
||||
LimitsMemory *string `json:"limits_memory,omitempty"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty"`
|
||||
|
||||
// Deployment hooks
|
||||
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty"`
|
||||
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty"`
|
||||
PostDeploymentCommand *string `json:"post_deployment_command,omitempty"`
|
||||
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty"`
|
||||
|
||||
// Misc
|
||||
Redirect *string `json:"redirect,omitempty"`
|
||||
WatchPaths *string `json:"watch_paths,omitempty"`
|
||||
IsStatic *bool `json:"is_static,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationLifecycleResponse represents the response from lifecycle operations
|
||||
type ApplicationLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID *string `json:"deployment_uuid,omitempty"`
|
||||
}
|
||||
|
||||
// ApplicationLogsResponse represents the response from logs endpoint
|
||||
type ApplicationLogsResponse struct {
|
||||
Logs string `json:"logs"`
|
||||
}
|
||||
|
||||
// EnvironmentVariable represents an environment variable for an application
|
||||
type EnvironmentVariable struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime bool `json:"is_build_time"`
|
||||
IsPreview bool `json:"is_preview"`
|
||||
IsLiteralValue bool `json:"is_literal"`
|
||||
IsShownOnce bool `json:"is_shown_once"`
|
||||
RealValue *string `json:"real_value,omitempty"`
|
||||
ApplicationID *int `json:"-" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// EnvironmentVariableCreateRequest represents the request to create an environment variable
|
||||
type EnvironmentVariableCreateRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
}
|
||||
|
||||
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
|
||||
type EnvironmentVariableUpdateRequest struct {
|
||||
UUID string `json:"uuid"`
|
||||
Key *string `json:"key,omitempty"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
IsBuildTime *bool `json:"is_build_time,omitempty"`
|
||||
IsPreview *bool `json:"is_preview,omitempty"`
|
||||
IsLiteral *bool `json:"is_literal,omitempty"`
|
||||
IsMultiline *bool `json:"is_multiline,omitempty"`
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package models
|
||||
|
||||
// Response wraps common API response fields
|
||||
type Response struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
}
|
||||
|
||||
// UUID is a common UUID field
|
||||
type UUID struct {
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
// Timestamps for created/updated times
|
||||
type Timestamps struct{
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package models
|
||||
|
||||
// Database represents a standalone Coolify database
|
||||
type Database struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"` // postgresql, mysql, mongodb, redis, etc.
|
||||
|
||||
// Network configuration
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits (hidden from CLI output)
|
||||
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty" table:"-"`
|
||||
LimitsCpuset *string `json:"limits_cpuset,omitempty" table:"-"`
|
||||
LimitsCpuShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
|
||||
|
||||
// PostgreSQL specific
|
||||
PostgresUser *string `json:"postgres_user,omitempty" table:"-"`
|
||||
PostgresPassword *string `json:"postgres_password,omitempty" table:"-"`
|
||||
PostgresDb *string `json:"postgres_db,omitempty" table:"-"`
|
||||
PostgresInitdbArgs *string `json:"postgres_initdb_args,omitempty" table:"-"`
|
||||
PostgresHostAuthMethod *string `json:"postgres_host_auth_method,omitempty" table:"-"`
|
||||
PostgresConf *string `json:"postgres_conf,omitempty" table:"-"`
|
||||
|
||||
// MySQL specific
|
||||
MysqlRootPassword *string `json:"mysql_root_password,omitempty" table:"-"`
|
||||
MysqlPassword *string `json:"mysql_password,omitempty" table:"-"`
|
||||
MysqlUser *string `json:"mysql_user,omitempty" table:"-"`
|
||||
MysqlDatabase *string `json:"mysql_database,omitempty" table:"-"`
|
||||
MysqlConf *string `json:"mysql_conf,omitempty" table:"-"`
|
||||
|
||||
// MariaDB specific
|
||||
MariadbRootPassword *string `json:"mariadb_root_password,omitempty" table:"-"`
|
||||
MariadbPassword *string `json:"mariadb_password,omitempty" table:"-"`
|
||||
MariadbUser *string `json:"mariadb_user,omitempty" table:"-"`
|
||||
MariadbDatabase *string `json:"mariadb_database,omitempty" table:"-"`
|
||||
MariadbConf *string `json:"mariadb_conf,omitempty" table:"-"`
|
||||
|
||||
// MongoDB specific
|
||||
MongoInitdbRootUsername *string `json:"mongo_initdb_root_username,omitempty" table:"-"`
|
||||
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty" table:"-"`
|
||||
MongoInitdbDatabase *string `json:"mongo_initdb_database,omitempty" table:"-"`
|
||||
MongoConf *string `json:"mongo_conf,omitempty" table:"-"`
|
||||
|
||||
// Redis specific
|
||||
RedisPassword *string `json:"redis_password,omitempty" table:"-"`
|
||||
RedisConf *string `json:"redis_conf,omitempty" table:"-"`
|
||||
|
||||
// KeyDB specific
|
||||
KeydbPassword *string `json:"keydb_password,omitempty" table:"-"`
|
||||
KeydbConf *string `json:"keydb_conf,omitempty" table:"-"`
|
||||
|
||||
// Clickhouse specific
|
||||
ClickhouseAdminUser *string `json:"clickhouse_admin_user,omitempty" table:"-"`
|
||||
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty" table:"-"`
|
||||
|
||||
// Dragonfly specific
|
||||
DragonflyPassword *string `json:"dragonfly_password,omitempty" table:"-"`
|
||||
|
||||
// Relationship IDs - internal database IDs (hidden from output)
|
||||
ServerID *int `json:"-" table:"-"`
|
||||
EnvironmentID *int `json:"-" table:"-"`
|
||||
ProjectID *int `json:"-" table:"-"`
|
||||
|
||||
// Metadata
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseCreateRequest represents the base request to create a database
|
||||
type DatabaseCreateRequest struct {
|
||||
ServerUUID string `json:"server_uuid"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
EnvironmentName *string `json:"environment_name,omitempty"`
|
||||
EnvironmentUUID *string `json:"environment_uuid,omitempty"`
|
||||
DestinationUUID *string `json:"destination_uuid,omitempty"`
|
||||
InstantDeploy *bool `json:"instant_deploy,omitempty"`
|
||||
|
||||
// Common fields
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits (hidden from CLI output)
|
||||
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
|
||||
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
|
||||
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
|
||||
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty" table:"-"`
|
||||
LimitsCpuset *string `json:"limits_cpuset,omitempty" table:"-"`
|
||||
LimitsCpuShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
|
||||
|
||||
// PostgreSQL specific
|
||||
PostgresUser *string `json:"postgres_user,omitempty"`
|
||||
PostgresPassword *string `json:"postgres_password,omitempty"`
|
||||
PostgresDb *string `json:"postgres_db,omitempty" table:"-"`
|
||||
PostgresInitdbArgs *string `json:"postgres_initdb_args,omitempty"`
|
||||
PostgresHostAuthMethod *string `json:"postgres_host_auth_method,omitempty"`
|
||||
PostgresConf *string `json:"postgres_conf,omitempty"`
|
||||
|
||||
// MySQL specific
|
||||
MysqlRootPassword *string `json:"mysql_root_password,omitempty"`
|
||||
MysqlPassword *string `json:"mysql_password,omitempty"`
|
||||
MysqlUser *string `json:"mysql_user,omitempty"`
|
||||
MysqlDatabase *string `json:"mysql_database,omitempty" table:"-"`
|
||||
MysqlConf *string `json:"mysql_conf,omitempty"`
|
||||
|
||||
// MariaDB specific
|
||||
MariadbRootPassword *string `json:"mariadb_root_password,omitempty"`
|
||||
MariadbPassword *string `json:"mariadb_password,omitempty"`
|
||||
MariadbUser *string `json:"mariadb_user,omitempty"`
|
||||
MariadbDatabase *string `json:"mariadb_database,omitempty" table:"-"`
|
||||
MariadbConf *string `json:"mariadb_conf,omitempty"`
|
||||
|
||||
// MongoDB specific
|
||||
MongoInitdbRootUsername *string `json:"mongo_initdb_root_username,omitempty"`
|
||||
MongoInitdbRootPassword *string `json:"mongo_initdb_root_password,omitempty"`
|
||||
MongoInitdbDatabase *string `json:"mongo_initdb_database,omitempty" table:"-"`
|
||||
MongoConf *string `json:"mongo_conf,omitempty"`
|
||||
|
||||
// Redis specific
|
||||
RedisPassword *string `json:"redis_password,omitempty"`
|
||||
RedisConf *string `json:"redis_conf,omitempty"`
|
||||
|
||||
// KeyDB specific
|
||||
KeydbPassword *string `json:"keydb_password,omitempty"`
|
||||
KeydbConf *string `json:"keydb_conf,omitempty"`
|
||||
|
||||
// Clickhouse specific
|
||||
ClickhouseAdminUser *string `json:"clickhouse_admin_user,omitempty"`
|
||||
ClickhouseAdminPassword *string `json:"clickhouse_admin_password,omitempty"`
|
||||
|
||||
// Dragonfly specific
|
||||
DragonflyPassword *string `json:"dragonfly_password,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseUpdateRequest represents the request to update a database
|
||||
// Only common configuration fields that make sense to update after creation
|
||||
type DatabaseUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
IsPublic *bool `json:"is_public,omitempty"`
|
||||
PublicPort *int `json:"public_port,omitempty"`
|
||||
|
||||
// Resource limits
|
||||
LimitsMemory *string `json:"limits_memory,omitempty"`
|
||||
LimitsCpus *string `json:"limits_cpus,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseLifecycleResponse represents the response from lifecycle operations
|
||||
type DatabaseLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// DatabaseBackup represents a scheduled database backup configuration
|
||||
type DatabaseBackup struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageID *int `json:"-" table:"-"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
DatabaseType *string `json:"database_type,omitempty" table:"-"`
|
||||
DatabaseID *int `json:"-" table:"-"`
|
||||
TeamID *int `json:"-" table:"-"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseBackupCreateRequest represents the request to create a backup configuration
|
||||
type DatabaseBackupCreateRequest struct {
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageUUID *string `json:"s3_storage_uuid,omitempty"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
Timeout *int `json:"timeout,omitempty"`
|
||||
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseBackupUpdateRequest represents the request to update a backup configuration
|
||||
type DatabaseBackupUpdateRequest struct {
|
||||
SaveS3 *bool `json:"save_s3,omitempty"`
|
||||
S3StorageUUID *string `json:"s3_storage_uuid,omitempty"`
|
||||
BackupNow *bool `json:"backup_now,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
|
||||
DumpAll *bool `json:"dump_all,omitempty"`
|
||||
Frequency *string `json:"frequency,omitempty"`
|
||||
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
|
||||
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageLocally *int `json:"database_backup_retention_max_storage_locally,omitempty"`
|
||||
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
|
||||
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
|
||||
DatabaseBackupRetentionMaxStorageS3 *int `json:"database_backup_retention_max_storage_s3,omitempty"`
|
||||
}
|
||||
|
||||
// DatabaseBackupExecution represents a single backup execution
|
||||
type DatabaseBackupExecution struct {
|
||||
UUID string `json:"uuid"`
|
||||
Filename *string `json:"filename,omitempty"`
|
||||
Size *int `json:"size,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// DatabaseBackupExecutionsResponse represents the response containing backup executions
|
||||
type DatabaseBackupExecutionsResponse struct {
|
||||
Executions []DatabaseBackupExecution `json:"executions"`
|
||||
}
|
||||
|
||||
// DatabaseBackupResponse represents a generic backup operation response
|
||||
type DatabaseBackupResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package models
|
||||
|
||||
// Deployment represents a deployment operation
|
||||
type Deployment struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"deployment_uuid"`
|
||||
ApplicationID *string `json:"application_id,omitempty" table:"-"`
|
||||
ApplicationName *string `json:"application_name,omitempty"`
|
||||
ServerName *string `json:"server_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Commit *string `json:"commit,omitempty"`
|
||||
CommitMessage *string `json:"commit_message,omitempty" table:"-"`
|
||||
// Additional fields from API that we want to ignore
|
||||
DeploymentURL *string `json:"deployment_url,omitempty" table:"-"`
|
||||
FinishedAt *string `json:"finished_at,omitempty" table:"-"`
|
||||
Logs *string `json:"logs,omitempty" table:"-"`
|
||||
CreatedAt *string `json:"created_at,omitempty" table:"-"`
|
||||
UpdatedAt *string `json:"updated_at,omitempty" table:"-"`
|
||||
}
|
||||
|
||||
// DeployResponse wraps deployment trigger responses
|
||||
type DeployResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid,omitempty"`
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package models
|
||||
|
||||
// Domain represents a domain configuration
|
||||
type Domain struct {
|
||||
IP string `json:"ip"`
|
||||
Domains []string `json:"domains"`
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package models
|
||||
|
||||
// GitHubApp represents a GitHub App integration
|
||||
type GitHubApp struct {
|
||||
ID int `json:"id" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL string `json:"api_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CustomUser string `json:"custom_user"`
|
||||
CustomPort int `json:"custom_port"`
|
||||
AppID int `json:"app_id" table:"-"`
|
||||
InstallationID int `json:"installation_id" table:"-"`
|
||||
ClientID string `json:"client_id" table:"-"`
|
||||
PrivateKeyID int `json:"private_key_id" table:"-"`
|
||||
IsSystemWide bool `json:"is_system_wide" table:"-"`
|
||||
TeamID int `json:"team_id" table:"-"`
|
||||
}
|
||||
|
||||
// GitHubAppCreateRequest represents a request to create a GitHub App
|
||||
type GitHubAppCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL string `json:"api_url"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CustomUser *string `json:"custom_user,omitempty"`
|
||||
CustomPort *int `json:"custom_port,omitempty"`
|
||||
AppID int `json:"app_id"`
|
||||
InstallationID int `json:"installation_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||
PrivateKeyUUID string `json:"private_key_uuid"`
|
||||
IsSystemWide *bool `json:"is_system_wide,omitempty"`
|
||||
}
|
||||
|
||||
// GitHubAppUpdateRequest represents a request to update a GitHub App
|
||||
type GitHubAppUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Organization *string `json:"organization,omitempty"`
|
||||
APIURL *string `json:"api_url,omitempty"`
|
||||
HTMLURL *string `json:"html_url,omitempty"`
|
||||
CustomUser *string `json:"custom_user,omitempty"`
|
||||
CustomPort *int `json:"custom_port,omitempty"`
|
||||
AppID *int `json:"app_id,omitempty"`
|
||||
InstallationID *int `json:"installation_id,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
ClientSecret *string `json:"client_secret,omitempty"`
|
||||
WebhookSecret *string `json:"webhook_secret,omitempty"`
|
||||
PrivateKeyUUID *string `json:"private_key_uuid,omitempty"`
|
||||
IsSystemWide *bool `json:"is_system_wide,omitempty"`
|
||||
}
|
||||
|
||||
// GitHubRepository represents a repository from GitHub
|
||||
type GitHubRepository struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Private bool `json:"private"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
}
|
||||
|
||||
// GitHubBranch represents a branch from GitHub
|
||||
type GitHubBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServer_MarshalUnmarshal(t *testing.T) {
|
||||
server := Server{
|
||||
ID: 1,
|
||||
UUID: "test-uuid",
|
||||
Name: "test-server",
|
||||
IP: "192.168.1.100",
|
||||
User: "root",
|
||||
Port: 22,
|
||||
Settings: Settings{
|
||||
IsReachable: true,
|
||||
IsUsable: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(server)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Server
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, server.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, server.Name, unmarshaled.Name)
|
||||
assert.Equal(t, server.IP, unmarshaled.IP)
|
||||
assert.True(t, unmarshaled.Settings.IsReachable)
|
||||
}
|
||||
|
||||
func TestServer_UnmarshalFromFixture(t *testing.T) {
|
||||
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "server.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var server Server
|
||||
err = json.Unmarshal(fixtureData, &server)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "550e8400-e29b-41d4-a716-446655440000", server.UUID)
|
||||
assert.Equal(t, "production-server", server.Name)
|
||||
assert.Equal(t, "192.168.1.100", server.IP)
|
||||
assert.True(t, server.Settings.IsReachable)
|
||||
}
|
||||
|
||||
func TestProject_MarshalUnmarshal(t *testing.T) {
|
||||
desc := "Test project"
|
||||
project := Project{
|
||||
UUID: "proj-uuid",
|
||||
Name: "My Project",
|
||||
Description: &desc,
|
||||
Environments: []Environment{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "env-uuid",
|
||||
Name: "production",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(project)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Project
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, project.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, project.Name, unmarshaled.Name)
|
||||
assert.NotNil(t, unmarshaled.Description)
|
||||
assert.Equal(t, "Test project", *unmarshaled.Description)
|
||||
assert.Len(t, unmarshaled.Environments, 1)
|
||||
}
|
||||
|
||||
func TestProject_UnmarshalFromFixture(t *testing.T) {
|
||||
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "project.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
var project Project
|
||||
err = json.Unmarshal(fixtureData, &project)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "proj-123-uuid", project.UUID)
|
||||
assert.Equal(t, "My Project", project.Name)
|
||||
assert.Len(t, project.Environments, 1)
|
||||
assert.Len(t, project.Environments[0].Applications, 1)
|
||||
assert.Equal(t, "running", project.Environments[0].Applications[0].Status)
|
||||
}
|
||||
|
||||
func TestResource_MarshalUnmarshal(t *testing.T) {
|
||||
resource := Resource{
|
||||
ID: 1,
|
||||
UUID: "resource-uuid",
|
||||
Name: "test-resource",
|
||||
Type: "application",
|
||||
Status: ResourceStatusRunning,
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(resource)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Resource
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, resource.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, resource.Status, unmarshaled.Status)
|
||||
}
|
||||
|
||||
func TestDeployment_MarshalUnmarshal(t *testing.T) {
|
||||
deployment := Deployment{
|
||||
Message: "Deployment started",
|
||||
ResourceUUID: "resource-uuid",
|
||||
DeploymentUUID: "deployment-uuid",
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(deployment)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Deployment
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, deployment.Message, unmarshaled.Message)
|
||||
assert.Equal(t, deployment.ResourceUUID, unmarshaled.ResourceUUID)
|
||||
}
|
||||
|
||||
func TestDomain_MarshalUnmarshal(t *testing.T) {
|
||||
domain := Domain{
|
||||
IP: "192.168.1.100",
|
||||
Domains: []string{"example.com", "www.example.com"},
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(domain)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled Domain
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, domain.IP, unmarshaled.IP)
|
||||
assert.Len(t, unmarshaled.Domains, 2)
|
||||
}
|
||||
|
||||
func TestPrivateKey_MarshalUnmarshal(t *testing.T) {
|
||||
key := PrivateKey{
|
||||
ID: 1,
|
||||
UUID: "key-uuid",
|
||||
Name: "test-key",
|
||||
PublicKey: "ssh-rsa AAAA...",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
|
||||
}
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var unmarshaled PrivateKey
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, key.UUID, unmarshaled.UUID)
|
||||
assert.Equal(t, key.Name, unmarshaled.Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyCreateRequest_Marshal(t *testing.T) {
|
||||
request := PrivateKeyCreateRequest{
|
||||
Name: "my-key",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\n...",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
var unmarshaled PrivateKeyCreateRequest
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, request.Name, unmarshaled.Name)
|
||||
assert.Equal(t, request.PrivateKey, unmarshaled.PrivateKey)
|
||||
}
|
||||
|
||||
func TestServerCreateRequest_Marshal(t *testing.T) {
|
||||
request := ServerCreateRequest{
|
||||
Name: "new-server",
|
||||
IP: "192.168.1.200",
|
||||
Port: 22,
|
||||
User: "root",
|
||||
PrivateKeyUUID: "key-uuid",
|
||||
InstantValidate: true,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(request)
|
||||
require.NoError(t, err)
|
||||
|
||||
var unmarshaled ServerCreateRequest
|
||||
err = json.Unmarshal(data, &unmarshaled)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, request.Name, unmarshaled.Name)
|
||||
assert.Equal(t, request.IP, unmarshaled.IP)
|
||||
assert.Equal(t, request.Port, unmarshaled.Port)
|
||||
assert.True(t, unmarshaled.InstantValidate)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package models
|
||||
|
||||
// PrivateKey represents an SSH private key
|
||||
type PrivateKey struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key" sensitive:"true"`
|
||||
PrivateKey string `json:"private_key" sensitive:"true"`
|
||||
}
|
||||
|
||||
// PrivateKeyCreateRequest for creating keys
|
||||
type PrivateKeyCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package models
|
||||
|
||||
// Project represents a Coolify project
|
||||
type Project struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Environments []Environment `json:"environments,omitempty"`
|
||||
}
|
||||
|
||||
// Environment within a project
|
||||
type Environment struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Applications []ApplicationInProject `json:"applications,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// ApplicationInProject represents a simplified application within an environment
|
||||
type ApplicationInProject struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ProjectCreateRequest for creating projects
|
||||
type ProjectCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package models
|
||||
|
||||
// Resource represents any deployable resource
|
||||
type Resource struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResourceStatus constants
|
||||
const (
|
||||
ResourceStatusRunning = "running"
|
||||
ResourceStatusStopped = "stopped"
|
||||
ResourceStatusError = "error"
|
||||
)
|
||||
|
||||
// Resources wraps a list of resources
|
||||
type Resources struct {
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
// Server represents a Coolify server
|
||||
type Server struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip" sensitive:"true"`
|
||||
User string `json:"user" sensitive:"true"`
|
||||
Port int `json:"port" sensitive:"true"`
|
||||
Settings Settings `json:"settings" table:"-"`
|
||||
}
|
||||
|
||||
// Settings for server
|
||||
type Settings struct {
|
||||
IsReachable bool `json:"is_reachable"`
|
||||
IsUsable bool `json:"is_usable"`
|
||||
}
|
||||
|
||||
// ServerCreateRequest for creating servers
|
||||
type ServerCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
PrivateKeyUUID string `json:"private_key_uuid"`
|
||||
InstantValidate bool `json:"instant_validate"`
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package models
|
||||
|
||||
// Service represents a Coolify one-click service
|
||||
type Service struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// Relationship IDs - internal database IDs (hidden from output)
|
||||
ServerID *int `json:"-" table:"-"`
|
||||
EnvironmentID *int `json:"-" table:"-"`
|
||||
ProjectID *int `json:"-" table:"-"`
|
||||
|
||||
// Docker configuration (hidden from table output)
|
||||
DockerCompose *string `json:"docker_compose,omitempty" table:"-"`
|
||||
DockerComposeRaw *string `json:"docker_compose_raw,omitempty" table:"-"`
|
||||
|
||||
// Additional metadata
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
|
||||
// Nested resources
|
||||
Applications []ServiceApplication `json:"applications,omitempty"`
|
||||
Databases []ServiceDatabase `json:"databases,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceApplication represents an application within a service
|
||||
type ServiceApplication struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Fqdn *string `json:"fqdn,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceDatabase represents a database within a service
|
||||
type ServiceDatabase struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceCreateRequest represents the request to create a service
|
||||
type ServiceCreateRequest struct {
|
||||
Type string `json:"type"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ServerUUID string `json:"server_uuid"`
|
||||
ProjectUUID string `json:"project_uuid"`
|
||||
EnvironmentName string `json:"environment_name"`
|
||||
InstantDeploy *bool `json:"instant_deploy,omitempty"`
|
||||
DockerCompose *string `json:"docker_compose,omitempty"`
|
||||
Destination *string `json:"destination,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceUpdateRequest represents the request to update a service
|
||||
type ServiceUpdateRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DockerCompose *string `json:"docker_compose,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceLifecycleResponse represents the response from lifecycle operations
|
||||
type ServiceLifecycleResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package models
|
||||
|
||||
// Team represents a Coolify team
|
||||
type Team struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
|
||||
// TeamMember represents a member of a team
|
||||
type TeamMember struct {
|
||||
ID int `json:"-" table:"-"`
|
||||
UUID string `json:"uuid" table:"-"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email" sensitive:"true"`
|
||||
Role *string `json:"role,omitempty" table:"-"`
|
||||
CreatedAt string `json:"-" table:"-"`
|
||||
UpdatedAt string `json:"-" table:"-"`
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Format types
|
||||
const (
|
||||
FormatTable = "table"
|
||||
FormatJSON = "json"
|
||||
FormatPretty = "pretty"
|
||||
)
|
||||
|
||||
// Formatter is the interface for output formatting
|
||||
type Formatter interface {
|
||||
// Format formats the data and writes it to the writer
|
||||
Format(data interface{}) error
|
||||
}
|
||||
|
||||
// Options for formatter configuration
|
||||
type Options struct {
|
||||
Writer io.Writer
|
||||
ShowSensitive bool
|
||||
Color bool
|
||||
}
|
||||
|
||||
// NewFormatter creates a formatter based on the format type
|
||||
func NewFormatter(format string, opts Options) (Formatter, error) {
|
||||
if opts.Writer == nil {
|
||||
opts.Writer = os.Stdout
|
||||
}
|
||||
|
||||
switch format {
|
||||
case FormatTable:
|
||||
return NewTableFormatter(opts), nil
|
||||
case FormatJSON:
|
||||
return NewJSONFormatter(opts), nil
|
||||
case FormatPretty:
|
||||
return NewPrettyFormatter(opts), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
// SensitiveOverlay is the string used to hide sensitive information
|
||||
const SensitiveOverlay = "********"
|
||||
@@ -1,267 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type TestServer struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func TestNewFormatter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format string
|
||||
wantErr bool
|
||||
}{
|
||||
{"table format", FormatTable, false},
|
||||
{"json format", FormatJSON, false},
|
||||
{"pretty format", FormatPretty, false},
|
||||
{"invalid format", "invalid", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := Options{Writer: &bytes.Buffer{}}
|
||||
formatter, err := NewFormatter(tt.format, opts)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, formatter)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, formatter)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONFormatter(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
{UUID: "uuid-2", Name: "server-2", Status: "stopped"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewJSONFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify valid JSON
|
||||
var result []TestServer
|
||||
err = json.Unmarshal(buf.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "uuid-1", result[0].UUID)
|
||||
assert.Equal(t, "server-1", result[0].Name)
|
||||
}
|
||||
|
||||
func TestPrettyFormatter(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewPrettyFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Verify it's indented JSON
|
||||
assert.Contains(t, output, " ")
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "server-1")
|
||||
|
||||
// Verify valid JSON
|
||||
var result []TestServer
|
||||
err = json.Unmarshal(buf.Bytes(), &result)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestTableFormatter_Slice(t *testing.T) {
|
||||
servers := []TestServer{
|
||||
{UUID: "uuid-1", Name: "server-1", Status: "running"},
|
||||
{UUID: "uuid-2", Name: "server-2", Status: "stopped"},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check headers
|
||||
assert.Contains(t, output, "uuid")
|
||||
assert.Contains(t, output, "name")
|
||||
assert.Contains(t, output, "status")
|
||||
|
||||
// Check data
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "server-1")
|
||||
assert.Contains(t, output, "running")
|
||||
assert.Contains(t, output, "uuid-2")
|
||||
assert.Contains(t, output, "server-2")
|
||||
assert.Contains(t, output, "stopped")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SingleStruct(t *testing.T) {
|
||||
server := TestServer{
|
||||
UUID: "uuid-1",
|
||||
Name: "server-1",
|
||||
Status: "running",
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(server)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check field names and values
|
||||
assert.Contains(t, output, "uuid")
|
||||
assert.Contains(t, output, "uuid-1")
|
||||
assert.Contains(t, output, "name")
|
||||
assert.Contains(t, output, "server-1")
|
||||
assert.Contains(t, output, "status")
|
||||
assert.Contains(t, output, "running")
|
||||
}
|
||||
|
||||
func TestTableFormatter_EmptySlice(t *testing.T) {
|
||||
var servers []TestServer
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(servers)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
assert.Contains(t, output, "No data")
|
||||
}
|
||||
|
||||
func TestTableFormatter_Map(t *testing.T) {
|
||||
data := map[string]string{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check headers
|
||||
assert.Contains(t, output, "Key")
|
||||
assert.Contains(t, output, "Value")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SimpleSlice(t *testing.T) {
|
||||
data := []string{"item1", "item2", "item3"}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
assert.Contains(t, output, "item1")
|
||||
assert.Contains(t, output, "item2")
|
||||
assert.Contains(t, output, "item3")
|
||||
}
|
||||
|
||||
func TestTableFormatter_BooleanValues(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Enabled: true, Active: false},
|
||||
{Name: "test2", Enabled: false, Active: true},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Check boolean formatting
|
||||
lines := strings.Split(output, "\n")
|
||||
assert.Contains(t, lines[1], "true")
|
||||
assert.Contains(t, lines[1], "false")
|
||||
assert.Contains(t, lines[2], "false")
|
||||
assert.Contains(t, lines[2], "true")
|
||||
}
|
||||
|
||||
func TestTableFormatter_NilPointer(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
desc := "test description"
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Description: &desc},
|
||||
{Name: "test2", Description: nil},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// First row should have description
|
||||
assert.Contains(t, output, "test description")
|
||||
// Second row should handle nil gracefully
|
||||
assert.Contains(t, output, "test2")
|
||||
}
|
||||
|
||||
func TestTableFormatter_SliceField(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
data := []TestStruct{
|
||||
{Name: "test1", Tags: []string{"tag1", "tag2", "tag3"}},
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
formatter := NewTableFormatter(Options{Writer: buf})
|
||||
|
||||
err := formatter.Format(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
output := buf.String()
|
||||
|
||||
// Tags should be comma-separated
|
||||
assert.Contains(t, output, "tag1, tag2, tag3")
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// JSONFormatter formats output as compact JSON
|
||||
type JSONFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewJSONFormatter creates a new JSON formatter
|
||||
func NewJSONFormatter(opts Options) *JSONFormatter {
|
||||
return &JSONFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as compact JSON
|
||||
func (f *JSONFormatter) Format(data interface{}) error {
|
||||
encoder := json.NewEncoder(f.opts.Writer)
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// PrettyFormatter formats output as indented JSON
|
||||
type PrettyFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewPrettyFormatter creates a new pretty JSON formatter
|
||||
func NewPrettyFormatter(opts Options) *PrettyFormatter {
|
||||
return &PrettyFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as indented JSON
|
||||
func (f *PrettyFormatter) Format(data interface{}) error {
|
||||
encoder := json.NewEncoder(f.opts.Writer)
|
||||
encoder.SetIndent("", " ")
|
||||
return encoder.Encode(data)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// TableFormatter formats output as a table
|
||||
type TableFormatter struct {
|
||||
opts Options
|
||||
}
|
||||
|
||||
// NewTableFormatter creates a new table formatter
|
||||
func NewTableFormatter(opts Options) *TableFormatter {
|
||||
return &TableFormatter{opts: opts}
|
||||
}
|
||||
|
||||
// Format formats the data as a table
|
||||
func (f *TableFormatter) Format(data interface{}) error {
|
||||
w := tabwriter.NewWriter(f.opts.Writer, 0, 0, 2, ' ', tabwriter.Debug)
|
||||
defer w.Flush()
|
||||
|
||||
// Handle different data types
|
||||
val := reflect.ValueOf(data)
|
||||
|
||||
// Dereference pointer if needed
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
switch val.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
return f.formatSlice(w, val)
|
||||
case reflect.Struct:
|
||||
return f.formatStruct(w, val)
|
||||
case reflect.Map:
|
||||
return f.formatMap(w, val)
|
||||
default:
|
||||
return fmt.Errorf("unsupported data type for table format: %v", val.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
// formatSlice formats a slice of structs as a table
|
||||
func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) error {
|
||||
if val.Len() == 0 {
|
||||
fmt.Fprintln(w, "No data")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get the first element to determine columns
|
||||
firstElem := val.Index(0)
|
||||
if firstElem.Kind() == reflect.Ptr {
|
||||
firstElem = firstElem.Elem()
|
||||
}
|
||||
|
||||
if firstElem.Kind() != reflect.Struct {
|
||||
// Simple slice (e.g., []string)
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
fmt.Fprintf(w, "%v\n", val.Index(i).Interface())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get column headers from struct tags or field names
|
||||
headers := f.getHeaders(firstElem.Type())
|
||||
// Add # as first column header
|
||||
headersWithNum := append([]string{"#"}, headers...)
|
||||
fmt.Fprintln(w, strings.Join(headersWithNum, "\t"))
|
||||
|
||||
// Print rows
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
elem := val.Index(i)
|
||||
if elem.Kind() == reflect.Ptr {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
row := f.formatStructRow(elem)
|
||||
// Add row number (1-indexed) as first column
|
||||
rowWithNum := append([]string{fmt.Sprintf("%d", i+1)}, row...)
|
||||
fmt.Fprintln(w, strings.Join(rowWithNum, "\t"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatStruct formats a single struct as a table (horizontal layout with headers)
|
||||
func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) error {
|
||||
// Get headers
|
||||
headers := f.getHeaders(val.Type())
|
||||
fmt.Fprintln(w, strings.Join(headers, "\t"))
|
||||
|
||||
// Get row data
|
||||
row := f.formatStructRow(val)
|
||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatMap formats a map as a table
|
||||
func (f *TableFormatter) formatMap(w *tabwriter.Writer, val reflect.Value) error {
|
||||
fmt.Fprintln(w, "Key\tValue")
|
||||
|
||||
iter := val.MapRange()
|
||||
for iter.Next() {
|
||||
key := iter.Key()
|
||||
value := iter.Value()
|
||||
fmt.Fprintf(w, "%v\t%v\n", key.Interface(), f.formatValue(value))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getHeaders extracts column headers from struct type
|
||||
func (f *TableFormatter) getHeaders(typ reflect.Type) []string {
|
||||
var headers []string
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
field := typ.Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check table tag for skip
|
||||
if tableTag := field.Tag.Get("table"); tableTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := field.Name
|
||||
// Use json tag if available
|
||||
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||
fieldName = strings.Split(jsonTag, ",")[0]
|
||||
if fieldName == "-" || fieldName == "omitempty" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
headers = append(headers, fieldName)
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// formatStructRow extracts values from a struct as a row
|
||||
func (f *TableFormatter) formatStructRow(val reflect.Value) []string {
|
||||
var row []string
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
|
||||
// Skip unexported fields
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check table tag for skip
|
||||
if tableTag := field.Tag.Get("table"); tableTag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check json tag for skip
|
||||
if jsonTag := field.Tag.Get("json"); jsonTag != "" {
|
||||
fieldName := strings.Split(jsonTag, ",")[0]
|
||||
if fieldName == "-" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
value := val.Field(i)
|
||||
|
||||
// Check if field is marked as sensitive
|
||||
isSensitive := field.Tag.Get("sensitive") == "true"
|
||||
if isSensitive && !f.opts.ShowSensitive {
|
||||
row = append(row, SensitiveOverlay)
|
||||
} else {
|
||||
row = append(row, f.formatValue(value))
|
||||
}
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
// formatValue formats a reflect.Value for display
|
||||
func (f *TableFormatter) formatValue(val reflect.Value) string {
|
||||
// Handle nil pointers
|
||||
if val.Kind() == reflect.Ptr && val.IsNil() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Dereference pointer
|
||||
if val.Kind() == reflect.Ptr {
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
// Handle different types
|
||||
switch val.Kind() {
|
||||
case reflect.String:
|
||||
return val.String()
|
||||
case reflect.Bool:
|
||||
if val.Bool() {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return fmt.Sprintf("%d", val.Int())
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
return fmt.Sprintf("%d", val.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return fmt.Sprintf("%.2f", val.Float())
|
||||
case reflect.Slice, reflect.Array:
|
||||
if val.Len() == 0 {
|
||||
return "[]"
|
||||
}
|
||||
// Check if it's a slice of structs
|
||||
elemType := val.Index(0).Kind()
|
||||
if elemType == reflect.Struct || elemType == reflect.Ptr {
|
||||
// For complex types, try to extract Name field from all elements
|
||||
var names []string
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
elem := val.Index(i)
|
||||
if elem.Kind() == reflect.Ptr && !elem.IsNil() {
|
||||
elem = elem.Elem()
|
||||
}
|
||||
if elem.Kind() == reflect.Struct {
|
||||
nameField := elem.FieldByName("Name")
|
||||
if nameField.IsValid() && nameField.Kind() == reflect.String {
|
||||
names = append(names, nameField.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return fmt.Sprintf("(%d items)", val.Len())
|
||||
}
|
||||
// For simple types, show comma-separated values
|
||||
var items []string
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
items = append(items, f.formatValue(val.Index(i)))
|
||||
}
|
||||
return strings.Join(items, ", ")
|
||||
case reflect.Struct:
|
||||
// For nested structs, try to show a name field if available
|
||||
nameField := val.FieldByName("Name")
|
||||
if nameField.IsValid() && nameField.Kind() == reflect.String {
|
||||
return nameField.String()
|
||||
}
|
||||
return fmt.Sprintf("(%s)", val.Type().Name())
|
||||
default:
|
||||
return fmt.Sprintf("%v", val.Interface())
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvVar represents a parsed environment variable
|
||||
type EnvVar struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ParseEnvFile parses a .env file and returns a slice of environment variables
|
||||
// Supports:
|
||||
// - KEY=value
|
||||
// - KEY="value"
|
||||
// - KEY='value'
|
||||
// - Multiline values with quotes
|
||||
// - Comments (lines starting with #)
|
||||
// - Empty lines
|
||||
func ParseEnvFile(filepath string) ([]EnvVar, error) {
|
||||
file, err := os.Open(filepath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var envVars []EnvVar
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNum := 0
|
||||
var currentVar *EnvVar
|
||||
var inMultiline bool
|
||||
var quoteChar rune
|
||||
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
line := scanner.Text()
|
||||
|
||||
// Handle multiline continuation
|
||||
if inMultiline {
|
||||
currentVar.Value += "\n" + line
|
||||
// Check if this line closes the multiline value
|
||||
if strings.HasSuffix(line, string(quoteChar)) {
|
||||
// Remove the closing quote
|
||||
currentVar.Value = strings.TrimSuffix(currentVar.Value, string(quoteChar))
|
||||
envVars = append(envVars, *currentVar)
|
||||
currentVar = nil
|
||||
inMultiline = false
|
||||
quoteChar = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip empty lines and comments
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the first = sign
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid format at line %d: missing '='", lineNum)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := parts[1]
|
||||
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("invalid format at line %d: empty key", lineNum)
|
||||
}
|
||||
|
||||
// Handle quoted values
|
||||
if len(value) >= 2 {
|
||||
firstChar := rune(value[0])
|
||||
if firstChar == '"' || firstChar == '\'' {
|
||||
// Check if the closing quote is on the same line
|
||||
if strings.HasSuffix(value, string(firstChar)) && len(value) > 1 {
|
||||
// Single-line quoted value
|
||||
value = value[1 : len(value)-1]
|
||||
envVars = append(envVars, EnvVar{Key: key, Value: value})
|
||||
} else {
|
||||
// Start of multiline quoted value
|
||||
currentVar = &EnvVar{Key: key, Value: value[1:]} // Remove opening quote
|
||||
inMultiline = true
|
||||
quoteChar = firstChar
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Unquoted value
|
||||
envVars = append(envVars, EnvVar{Key: key, Value: value})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error reading file: %w", err)
|
||||
}
|
||||
|
||||
if inMultiline {
|
||||
return nil, fmt.Errorf("unclosed quoted value for key '%s'", currentVar.Key)
|
||||
}
|
||||
|
||||
return envVars, nil
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseEnvFile_Simple(t *testing.T) {
|
||||
content := `KEY1=value1
|
||||
KEY2=value2
|
||||
KEY3=value3`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 3)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
assert.Equal(t, "KEY3", envVars[2].Key)
|
||||
assert.Equal(t, "value3", envVars[2].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithQuotes(t *testing.T) {
|
||||
content := `KEY1="value with spaces"
|
||||
KEY2='single quoted value'
|
||||
KEY3=unquoted`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 3)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value with spaces", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "single quoted value", envVars[1].Value)
|
||||
assert.Equal(t, "KEY3", envVars[2].Key)
|
||||
assert.Equal(t, "unquoted", envVars[2].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithComments(t *testing.T) {
|
||||
content := `# This is a comment
|
||||
KEY1=value1
|
||||
# Another comment
|
||||
KEY2=value2`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_WithEmptyLines(t *testing.T) {
|
||||
content := `KEY1=value1
|
||||
|
||||
KEY2=value2
|
||||
|
||||
`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value1", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value2", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_Multiline(t *testing.T) {
|
||||
content := `KEY1="line1
|
||||
line2
|
||||
line3"
|
||||
KEY2=single`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "line1\nline2\nline3", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "single", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_MultilineWithSingleQuotes(t *testing.T) {
|
||||
content := `PRIVATE_KEY='-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC
|
||||
-----END PRIVATE KEY-----'
|
||||
OTHER=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "PRIVATE_KEY", envVars[0].Key)
|
||||
assert.Contains(t, envVars[0].Value, "BEGIN PRIVATE KEY")
|
||||
assert.Contains(t, envVars[0].Value, "END PRIVATE KEY")
|
||||
assert.Equal(t, "OTHER", envVars[1].Key)
|
||||
assert.Equal(t, "value", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EmptyValue(t *testing.T) {
|
||||
content := `KEY1=
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "value", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EqualsInValue(t *testing.T) {
|
||||
content := `KEY1=value=with=equals
|
||||
KEY2="quoted=value=with=equals"`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 2)
|
||||
assert.Equal(t, "KEY1", envVars[0].Key)
|
||||
assert.Equal(t, "value=with=equals", envVars[0].Value)
|
||||
assert.Equal(t, "KEY2", envVars[1].Key)
|
||||
assert.Equal(t, "quoted=value=with=equals", envVars[1].Value)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InvalidFormat_MissingEquals(t *testing.T) {
|
||||
content := `KEY1
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "missing '='")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_InvalidFormat_EmptyKey(t *testing.T) {
|
||||
content := `=value
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty key")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_UnclosedQuote(t *testing.T) {
|
||||
content := `KEY1="unclosed quote
|
||||
KEY2=value`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
_, err := ParseEnvFile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unclosed quoted value")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_FileNotFound(t *testing.T) {
|
||||
_, err := ParseEnvFile("/nonexistent/file.env")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to open file")
|
||||
}
|
||||
|
||||
func TestParseEnvFile_EmptyFile(t *testing.T) {
|
||||
content := ``
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 0)
|
||||
}
|
||||
|
||||
func TestParseEnvFile_OnlyComments(t *testing.T) {
|
||||
content := `# Comment 1
|
||||
# Comment 2
|
||||
# Comment 3`
|
||||
|
||||
tmpFile := createTempEnvFile(t, content)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
envVars, err := ParseEnvFile(tmpFile)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envVars, 0)
|
||||
}
|
||||
|
||||
// Helper function to create a temporary .env file
|
||||
func createTempEnvFile(t *testing.T, content string) string {
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, ".env")
|
||||
err := os.WriteFile(tmpFile, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
return tmpFile
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ApplicationService handles application-related operations
|
||||
type ApplicationService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewApplicationService creates a new application service
|
||||
func NewApplicationService(client *api.Client) *ApplicationService {
|
||||
return &ApplicationService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all applications
|
||||
func (s *ApplicationService) List(ctx context.Context) ([]models.Application, error) {
|
||||
var apps []models.Application
|
||||
err := s.client.Get(ctx, "applications", &apps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list applications: %w", err)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific application by UUID
|
||||
func (s *ApplicationService) Get(ctx context.Context, uuid string) (*models.Application, error) {
|
||||
var app models.Application
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s", uuid), &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get application %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Update updates an application
|
||||
func (s *ApplicationService) Update(ctx context.Context, uuid string, req models.ApplicationUpdateRequest) (*models.Application, error) {
|
||||
var app models.Application
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s", uuid), req, &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update application %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Delete deletes an application
|
||||
func (s *ApplicationService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete application %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts an application (initiates deployment)
|
||||
func (s *ApplicationService) Start(ctx context.Context, uuid string, force bool, instantDeploy bool) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
|
||||
// Build URL with query parameters
|
||||
url := fmt.Sprintf("applications/%s/start", uuid)
|
||||
if force || instantDeploy {
|
||||
url += "?"
|
||||
if force {
|
||||
url += "force=true"
|
||||
}
|
||||
if instantDeploy {
|
||||
if force {
|
||||
url += "&"
|
||||
}
|
||||
url += "instant_deploy=true"
|
||||
}
|
||||
}
|
||||
|
||||
err := s.client.Get(ctx, url, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Stop stops an application
|
||||
func (s *ApplicationService) Stop(ctx context.Context, uuid string) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/stop", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Restart restarts an application
|
||||
func (s *ApplicationService) Restart(ctx context.Context, uuid string) (*models.ApplicationLifecycleResponse, error) {
|
||||
var resp models.ApplicationLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/restart", uuid), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restart application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Logs retrieves logs for an application
|
||||
func (s *ApplicationService) Logs(ctx context.Context, uuid string, lines int) (*models.ApplicationLogsResponse, error) {
|
||||
url := fmt.Sprintf("applications/%s/logs", uuid)
|
||||
|
||||
// Add lines parameter if specified
|
||||
if lines > 0 {
|
||||
url = fmt.Sprintf("%s?lines=%d", url, lines)
|
||||
}
|
||||
|
||||
var resp models.ApplicationLogsResponse
|
||||
err := s.client.Get(ctx, url, &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get logs for application %s: %w", uuid, err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListEnvs retrieves all environment variables for an application
|
||||
func (s *ApplicationService) ListEnvs(ctx context.Context, uuid string) ([]models.EnvironmentVariable, error) {
|
||||
var envs []models.EnvironmentVariable
|
||||
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/envs", uuid), &envs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list environment variables for application %s: %w", uuid, err)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
// CreateEnv creates a new environment variable for an application
|
||||
func (s *ApplicationService) CreateEnv(ctx context.Context, uuid string, req *models.EnvironmentVariableCreateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Post(ctx, fmt.Sprintf("applications/%s/envs", uuid), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create environment variable for application %s: %w", uuid, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// UpdateEnv updates an existing environment variable for an application
|
||||
func (s *ApplicationService) UpdateEnv(ctx context.Context, appUUID string, req *models.EnvironmentVariableUpdateRequest) (*models.EnvironmentVariable, error) {
|
||||
var env models.EnvironmentVariable
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/envs", appUUID), req, &env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update environment variable for application %s: %w", appUUID, err)
|
||||
}
|
||||
return &env, nil
|
||||
}
|
||||
|
||||
// GetEnv retrieves a single environment variable by UUID or key
|
||||
func (s *ApplicationService) GetEnv(ctx context.Context, appUUID, envIdentifier string) (*models.EnvironmentVariable, error) {
|
||||
envs, err := s.ListEnvs(ctx, appUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Try to find by UUID first, then by key
|
||||
for _, env := range envs {
|
||||
if env.UUID == envIdentifier || env.Key == envIdentifier {
|
||||
return &env, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("environment variable '%s' not found in application %s", envIdentifier, appUUID)
|
||||
}
|
||||
|
||||
// DeleteEnv deletes an environment variable from an application
|
||||
func (s *ApplicationService) DeleteEnv(ctx context.Context, appUUID string, envUUID string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/envs/%s", appUUID, envUUID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete environment variable %s for application %s: %w", envUUID, appUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkUpdateEnvsRequest represents a bulk update request for environment variables
|
||||
type BulkUpdateEnvsRequest struct {
|
||||
Data []models.EnvironmentVariableCreateRequest `json:"data"`
|
||||
}
|
||||
|
||||
// BulkUpdateEnvsResponse represents the response from bulk update
|
||||
type BulkUpdateEnvsResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// BulkUpdateEnvs updates multiple environment variables in a single request
|
||||
func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
|
||||
var response BulkUpdateEnvsResponse
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/envs/bulk", appUUID), req, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to bulk update environment variables for application %s: %w", appUUID, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
@@ -1,800 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestApplicationService_List(t *testing.T) {
|
||||
desc1 := "App Description 1"
|
||||
desc2 := "App Description 2"
|
||||
branch1 := "main"
|
||||
branch2 := "develop"
|
||||
fqdn1 := "app1.example.com"
|
||||
fqdn2 := "app2.example.com"
|
||||
|
||||
applications := []models.Application{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-1",
|
||||
Name: "Test App 1",
|
||||
Description: &desc1,
|
||||
Status: "running",
|
||||
GitBranch: &branch1,
|
||||
FQDN: &fqdn1,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "app-uuid-2",
|
||||
Name: "Test App 2",
|
||||
Description: &desc2,
|
||||
Status: "stopped",
|
||||
GitBranch: &branch2,
|
||||
FQDN: &fqdn2,
|
||||
CreatedAt: "2024-01-03T00:00:00Z",
|
||||
UpdatedAt: "2024-01-04T00:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(applications)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "app-uuid-1", result[0].UUID)
|
||||
assert.Equal(t, "Test App 1", result[0].Name)
|
||||
assert.Equal(t, "running", result[0].Status)
|
||||
assert.Equal(t, "main", *result[0].GitBranch)
|
||||
assert.Equal(t, "app-uuid-2", result[1].UUID)
|
||||
assert.Equal(t, "Test App 2", result[1].Name)
|
||||
assert.Equal(t, "stopped", result[1].Status)
|
||||
}
|
||||
|
||||
func TestApplicationService_List_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]models.Application{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_List_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to list applications")
|
||||
}
|
||||
|
||||
func TestApplicationService_Get(t *testing.T) {
|
||||
desc := "Test Application"
|
||||
branch := "main"
|
||||
fqdn := "test.example.com"
|
||||
|
||||
application := models.Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: "Test App",
|
||||
Description: &desc,
|
||||
Status: "running",
|
||||
GitBranch: &branch,
|
||||
FQDN: &fqdn,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(application)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-123", result.UUID)
|
||||
assert.Equal(t, "Test App", result.Name)
|
||||
assert.Equal(t, "running", result.Status)
|
||||
assert.Equal(t, "main", *result.GitBranch)
|
||||
assert.Equal(t, "test.example.com", *result.FQDN)
|
||||
}
|
||||
|
||||
func TestApplicationService_Get_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Get_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Get(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Update(t *testing.T) {
|
||||
newName := "Updated App Name"
|
||||
newBranch := "develop"
|
||||
newDesc := "Updated description"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
// Verify request body
|
||||
var req models.ApplicationUpdateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.NotNil(t, req.Name)
|
||||
assert.Equal(t, newName, *req.Name)
|
||||
assert.NotNil(t, req.GitBranch)
|
||||
assert.Equal(t, newBranch, *req.GitBranch)
|
||||
|
||||
// Return updated application
|
||||
updatedApp := models.Application{
|
||||
ID: 1,
|
||||
UUID: "app-uuid-123",
|
||||
Name: newName,
|
||||
Description: &newDesc,
|
||||
Status: "running",
|
||||
GitBranch: &newBranch,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-05T00:00:00Z",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(updatedApp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
GitBranch: &newBranch,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-123", result.UUID)
|
||||
assert.Equal(t, newName, result.Name)
|
||||
assert.Equal(t, newBranch, *result.GitBranch)
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_PartialUpdate(t *testing.T) {
|
||||
newDomains := "app.example.com,www.example.com"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-456", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
// Verify only domains field is in request
|
||||
var req models.ApplicationUpdateRequest
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
assert.Nil(t, req.Name)
|
||||
assert.Nil(t, req.GitBranch)
|
||||
assert.NotNil(t, req.Domains)
|
||||
assert.Equal(t, newDomains, *req.Domains)
|
||||
|
||||
// Return updated application
|
||||
fqdn := "app.example.com"
|
||||
updatedApp := models.Application{
|
||||
ID: 2,
|
||||
UUID: "app-uuid-456",
|
||||
Name: "Existing App",
|
||||
Status: "running",
|
||||
FQDN: &fqdn,
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
UpdatedAt: "2024-01-05T00:00:00Z",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(updatedApp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Domains: &newDomains,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-456", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "app-uuid-456", result.UUID)
|
||||
assert.Equal(t, "app.example.com", *result.FQDN)
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newName := "Updated Name"
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "non-existent-uuid", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Update_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newName := "Updated Name"
|
||||
req := models.ApplicationUpdateRequest{
|
||||
Name: &newName,
|
||||
}
|
||||
|
||||
result, err := svc.Update(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "non-existent-uuid")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Delete_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"internal server error"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Start(t *testing.T) {
|
||||
deploymentUUID := "deploy-uuid-123"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
DeploymentUUID: &deploymentUUID,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, false)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Deployment request queued.", result.Message)
|
||||
assert.NotNil(t, result.DeploymentUUID)
|
||||
assert.Equal(t, "deploy-uuid-123", *result.DeploymentUUID)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_WithForce(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "force=true", r.URL.RawQuery)
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", true, false)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_WithInstantDeploy(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/start", r.URL.Path)
|
||||
assert.Equal(t, "instant_deploy=true", r.URL.RawQuery)
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Deployment request queued.",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, true)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Start_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to start application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Start(context.Background(), "app-uuid-123", false, false)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to start application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Stop(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/stop", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Application stopped successfully",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Stop(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Application stopped successfully", result.Message)
|
||||
}
|
||||
|
||||
func TestApplicationService_Stop_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to stop application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Stop(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to stop application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Restart(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/restart", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLifecycleResponse{
|
||||
Message: "Application restarted successfully",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Restart(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Application restarted successfully", result.Message)
|
||||
}
|
||||
|
||||
func TestApplicationService_Restart_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"message":"failed to restart application"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Restart(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to restart application")
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/logs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
resp := models.ApplicationLogsResponse{
|
||||
Logs: "[2025-10-15 12:00:00] Application started\n[2025-10-15 12:00:01] Server listening on port 3000\n",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 0)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Contains(t, result.Logs, "Application started")
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs_WithLines(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/logs", r.URL.Path)
|
||||
assert.Equal(t, "lines=50", r.URL.RawQuery)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
resp := models.ApplicationLogsResponse{
|
||||
Logs: "[2025-10-15 12:00:00] Log line\n",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 50)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
|
||||
func TestApplicationService_Logs_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.Logs(context.Background(), "app-uuid-123", 0)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to get logs for application")
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
envs := []models.EnvironmentVariable{
|
||||
{
|
||||
ID: 1,
|
||||
UUID: "env-uuid-1",
|
||||
Key: "DATABASE_URL",
|
||||
Value: "********",
|
||||
IsBuildTime: false,
|
||||
IsPreview: false,
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
UUID: "env-uuid-2",
|
||||
Key: "API_KEY",
|
||||
Value: "********",
|
||||
IsBuildTime: true,
|
||||
IsPreview: false,
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(envs)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "DATABASE_URL", result[0].Key)
|
||||
assert.Equal(t, "API_KEY", result[1].Key)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs_Empty(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]models.EnvironmentVariable{})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 0)
|
||||
}
|
||||
|
||||
func TestApplicationService_ListEnvs_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"application not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
result, err := svc.ListEnvs(context.Background(), "app-uuid-123")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to list environment variables")
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
env := models.EnvironmentVariable{
|
||||
ID: 1,
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
IsBuildTime: false,
|
||||
IsPreview: false,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(env)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
isBuildTime := false
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
IsBuildTime: &isBuildTime,
|
||||
}
|
||||
|
||||
result, err := svc.CreateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "API_KEY", result.Key)
|
||||
assert.Equal(t, "env-uuid-1", result.UUID)
|
||||
}
|
||||
|
||||
func TestApplicationService_CreateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"message":"key already exists"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
req := &models.EnvironmentVariableCreateRequest{
|
||||
Key: "API_KEY",
|
||||
Value: "secret123",
|
||||
}
|
||||
|
||||
result, err := svc.CreateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to create environment variable")
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
|
||||
assert.Equal(t, "PATCH", r.Method)
|
||||
|
||||
env := models.EnvironmentVariable{
|
||||
UUID: "env-uuid-1",
|
||||
Key: "API_KEY",
|
||||
Value: "newsecret456",
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(env)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newValue := "newsecret456"
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: "env-uuid-1",
|
||||
Value: &newValue,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "API_KEY", result.Key)
|
||||
assert.Equal(t, "newsecret456", result.Value)
|
||||
}
|
||||
|
||||
func TestApplicationService_UpdateEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"environment variable not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
newValue := "newsecret456"
|
||||
req := &models.EnvironmentVariableUpdateRequest{
|
||||
UUID: "env-uuid-1",
|
||||
Value: &newValue,
|
||||
}
|
||||
|
||||
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
assert.Contains(t, err.Error(), "failed to update environment variable")
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteEnv(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs/env-uuid-1", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "app-uuid-123", "env-uuid-1")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestApplicationService_DeleteEnv_Error(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(`{"message":"environment variable not found"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewApplicationService(client)
|
||||
|
||||
err := svc.DeleteEnv(context.Background(), "app-uuid-123", "env-uuid-1")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to delete environment variable")
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DatabaseService handles database-related operations
|
||||
type DatabaseService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDatabaseService creates a new database service
|
||||
func NewDatabaseService(client *api.Client) *DatabaseService {
|
||||
return &DatabaseService{client: client}
|
||||
}
|
||||
|
||||
// List retrieves all databases
|
||||
func (s *DatabaseService) List(ctx context.Context) ([]models.Database, error) {
|
||||
var databases []models.Database
|
||||
err := s.client.Get(ctx, "databases", &databases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list databases: %w", err)
|
||||
}
|
||||
|
||||
// Infer database type if not provided by API
|
||||
for i := range databases {
|
||||
if databases[i].Type == "" {
|
||||
databases[i].Type = inferDatabaseType(&databases[i])
|
||||
}
|
||||
}
|
||||
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
// Get retrieves a database by UUID
|
||||
func (s *DatabaseService) Get(ctx context.Context, uuid string) (*models.Database, error) {
|
||||
var database models.Database
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s", uuid), &database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get database %s: %w", uuid, err)
|
||||
}
|
||||
|
||||
// Infer database type if not provided by API
|
||||
if database.Type == "" {
|
||||
database.Type = inferDatabaseType(&database)
|
||||
}
|
||||
|
||||
return &database, nil
|
||||
}
|
||||
|
||||
// Create creates a new database of the specified type
|
||||
func (s *DatabaseService) Create(ctx context.Context, dbType string, req *models.DatabaseCreateRequest) (*models.Database, error) {
|
||||
var database models.Database
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s", dbType), req, &database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s database: %w", dbType, err)
|
||||
}
|
||||
return &database, nil
|
||||
}
|
||||
|
||||
// Update updates a database
|
||||
func (s *DatabaseService) Update(ctx context.Context, uuid string, req *models.DatabaseUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s", uuid), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a database
|
||||
func (s *DatabaseService) Delete(ctx context.Context, uuid string, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks bool) error {
|
||||
url := fmt.Sprintf("databases/%s?delete_configurations=%t&delete_volumes=%t&docker_cleanup=%t&delete_connected_networks=%t",
|
||||
uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
|
||||
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete database %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts a database
|
||||
func (s *DatabaseService) Start(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/start", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Stop stops a database
|
||||
func (s *DatabaseService) Stop(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/stop", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to stop database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// Restart restarts a database
|
||||
func (s *DatabaseService) Restart(ctx context.Context, uuid string) (*models.DatabaseLifecycleResponse, error) {
|
||||
var response models.DatabaseLifecycleResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/restart", uuid), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to restart database %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// ListBackups retrieves all backup configurations for a database
|
||||
func (s *DatabaseService) ListBackups(ctx context.Context, uuid string) ([]models.DatabaseBackup, error) {
|
||||
var backups []models.DatabaseBackup
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/backups", uuid), &backups)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list backups for database %s: %w", uuid, err)
|
||||
}
|
||||
return backups, nil
|
||||
}
|
||||
|
||||
// CreateBackup creates a new scheduled backup configuration
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *DatabaseService) CreateBackup(ctx context.Context, uuid string, req *models.DatabaseBackupCreateRequest) (*models.DatabaseBackup, error) {
|
||||
var backup models.DatabaseBackup
|
||||
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/backups", uuid), req, &backup)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create backup for database %s: %w", uuid, err)
|
||||
}
|
||||
return &backup, nil
|
||||
}
|
||||
|
||||
// UpdateBackup updates a backup configuration
|
||||
func (s *DatabaseService) UpdateBackup(ctx context.Context, dbUUID, backupUUID string, req *models.DatabaseBackupUpdateRequest) error {
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/backups/%s", dbUUID, backupUUID), req, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update backup %s for database %s: %w", backupUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBackup deletes a backup configuration
|
||||
func (s *DatabaseService) DeleteBackup(ctx context.Context, dbUUID, backupUUID string, deleteS3 bool) error {
|
||||
url := fmt.Sprintf("databases/%s/backups/%s?delete_s3=%t", dbUUID, backupUUID, deleteS3)
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup %s for database %s: %w", backupUUID, dbUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackupExecutions retrieves all executions for a backup configuration
|
||||
func (s *DatabaseService) ListBackupExecutions(ctx context.Context, dbUUID, backupUUID string) ([]models.DatabaseBackupExecution, error) {
|
||||
var response models.DatabaseBackupExecutionsResponse
|
||||
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/backups/%s/executions", dbUUID, backupUUID), &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list backup executions for backup %s: %w", backupUUID, err)
|
||||
}
|
||||
return response.Executions, nil
|
||||
}
|
||||
|
||||
// DeleteBackupExecution deletes a specific backup execution
|
||||
func (s *DatabaseService) DeleteBackupExecution(ctx context.Context, dbUUID, backupUUID, executionUUID string, deleteS3 bool) error {
|
||||
url := fmt.Sprintf("databases/%s/backups/%s/executions/%s?delete_s3=%t", dbUUID, backupUUID, executionUUID, deleteS3)
|
||||
err := s.client.Delete(ctx, url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete backup execution %s: %w", executionUUID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inferDatabaseType determines the database type from available fields
|
||||
func inferDatabaseType(db *models.Database) string {
|
||||
// Check for PostgreSQL
|
||||
if db.PostgresUser != nil || db.PostgresPassword != nil || db.PostgresDb != nil {
|
||||
return "postgresql"
|
||||
}
|
||||
|
||||
// Check for MySQL
|
||||
if db.MysqlUser != nil || db.MysqlPassword != nil || db.MysqlDatabase != nil {
|
||||
return "mysql"
|
||||
}
|
||||
|
||||
// Check for MariaDB
|
||||
if db.MariadbUser != nil || db.MariadbPassword != nil || db.MariadbDatabase != nil {
|
||||
return "mariadb"
|
||||
}
|
||||
|
||||
// Check for MongoDB
|
||||
if db.MongoInitdbRootUsername != nil || db.MongoInitdbRootPassword != nil || db.MongoInitdbDatabase != nil {
|
||||
return "mongodb"
|
||||
}
|
||||
|
||||
// Check for Redis
|
||||
if db.RedisPassword != nil || db.RedisConf != nil {
|
||||
return "redis"
|
||||
}
|
||||
|
||||
// Check for KeyDB
|
||||
if db.KeydbPassword != nil || db.KeydbConf != nil {
|
||||
return "keydb"
|
||||
}
|
||||
|
||||
// Check for Clickhouse
|
||||
if db.ClickhouseAdminUser != nil || db.ClickhouseAdminPassword != nil {
|
||||
return "clickhouse"
|
||||
}
|
||||
|
||||
// Check for Dragonfly
|
||||
if db.DragonflyPassword != nil {
|
||||
return "dragonfly"
|
||||
}
|
||||
|
||||
// Fallback: try to infer from image name
|
||||
if db.Image != nil {
|
||||
image := *db.Image
|
||||
if len(image) > 0 {
|
||||
// Extract base image name (e.g., "postgres:16-alpine" -> "postgres")
|
||||
for i := 0; i < len(image); i++ {
|
||||
if image[i] == ':' {
|
||||
return image[:i]
|
||||
}
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,858 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDatabaseService_List(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
serverResponse: `[
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-1",
|
||||
"name": "Production PostgreSQL",
|
||||
"description": "Main database",
|
||||
"status": "running",
|
||||
"type": "postgresql",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
serverResponse: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "server error",
|
||||
serverResponse: `{"error":"internal server error"}`,
|
||||
statusCode: http.StatusInternalServerError,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
databases, err := dbService.List(context.Background())
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, databases, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Get(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantName string
|
||||
}{
|
||||
{
|
||||
name: "successful get",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-1",
|
||||
"name": "Production PostgreSQL",
|
||||
"description": "Main database",
|
||||
"status": "running",
|
||||
"type": "postgresql",
|
||||
"postgres_db": "myapp",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantName: "Production PostgreSQL",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
database, err := dbService.Get(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantName, database.Name)
|
||||
assert.Equal(t, tt.uuid, database.UUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbType string
|
||||
request *models.DatabaseCreateRequest
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantUUID string
|
||||
}{
|
||||
{
|
||||
name: "create postgresql",
|
||||
dbType: "postgresql",
|
||||
request: &models.DatabaseCreateRequest{
|
||||
ServerUUID: "server-uuid-1",
|
||||
ProjectUUID: "project-uuid-1",
|
||||
},
|
||||
serverResponse: `{
|
||||
"id": 1,
|
||||
"uuid": "db-uuid-new",
|
||||
"name": "New Database",
|
||||
"status": "starting",
|
||||
"type": "postgresql",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantUUID: "db-uuid-new",
|
||||
},
|
||||
{
|
||||
name: "validation error",
|
||||
dbType: "mysql",
|
||||
request: &models.DatabaseCreateRequest{
|
||||
ServerUUID: "server-uuid-1",
|
||||
},
|
||||
serverResponse: `{"error":"project_uuid is required"}`,
|
||||
statusCode: http.StatusUnprocessableEntity,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbType, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
|
||||
var req models.DatabaseCreateRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.request.ServerUUID, req.ServerUUID)
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
database, err := dbService.Create(context.Background(), tt.dbType, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantUUID, database.UUID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Update(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
request *models.DatabaseUpdateRequest
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
uuid: "db-uuid-1",
|
||||
request: &models.DatabaseUpdateRequest{
|
||||
Name: stringPtr("Updated Name"),
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
request: &models.DatabaseUpdateRequest{
|
||||
Name: stringPtr("Updated Name"),
|
||||
},
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
|
||||
var req models.DatabaseUpdateRequest
|
||||
err := json.NewDecoder(r.Body).Decode(&req)
|
||||
require.NoError(t, err)
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusNotFound {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.Update(context.Background(), tt.uuid, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Delete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
deleteConfigurations bool
|
||||
deleteVolumes bool
|
||||
dockerCleanup bool
|
||||
deleteConnectedNetworks bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
expectedQueryString string
|
||||
}{
|
||||
{
|
||||
name: "successful delete with all cleanup",
|
||||
uuid: "db-uuid-1",
|
||||
deleteConfigurations: true,
|
||||
deleteVolumes: true,
|
||||
dockerCleanup: true,
|
||||
deleteConnectedNetworks: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
expectedQueryString: "delete_configurations=true&delete_volumes=true&docker_cleanup=true&delete_connected_networks=true",
|
||||
},
|
||||
{
|
||||
name: "successful delete without cleanup",
|
||||
uuid: "db-uuid-2",
|
||||
deleteConfigurations: false,
|
||||
deleteVolumes: false,
|
||||
dockerCleanup: false,
|
||||
deleteConnectedNetworks: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
expectedQueryString: "delete_configurations=false&delete_volumes=false&docker_cleanup=false&delete_connected_networks=false",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
deleteConfigurations: true,
|
||||
deleteVolumes: true,
|
||||
dockerCleanup: true,
|
||||
deleteConnectedNetworks: true,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
if tt.expectedQueryString != "" {
|
||||
assert.Equal(t, tt.expectedQueryString, r.URL.RawQuery)
|
||||
}
|
||||
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Database deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.Delete(context.Background(), tt.uuid, tt.deleteConfigurations, tt.deleteVolumes, tt.dockerCleanup, tt.deleteConnectedNetworks)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Start(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful start",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database started successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database started successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/start", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Start(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Stop(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful stop",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database stopped successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database stopped successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/stop", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Stop(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_Restart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "successful restart",
|
||||
uuid: "db-uuid-1",
|
||||
serverResponse: `{"message":"Database restarted successfully"}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantMessage: "Database restarted successfully",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
uuid: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.uuid+"/restart", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
response, err := dbService.Restart(context.Background(), tt.uuid)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantMessage, response.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListBackups(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
dbUUID: "db-uuid-1",
|
||||
serverResponse: `[
|
||||
{
|
||||
"uuid": "backup-uuid-1",
|
||||
"enabled": true,
|
||||
"frequency": "0 2 * * *",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
dbUUID: "db-uuid-2",
|
||||
serverResponse: `[]`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
backups, err := dbService.ListBackups(context.Background(), tt.dbUUID)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, backups, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_UpdateBackup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
request *models.DatabaseBackupUpdateRequest
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful update",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
request: &models.DatabaseBackupUpdateRequest{
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
request: &models.DatabaseBackupUpdateRequest{
|
||||
Enabled: boolPtr(false),
|
||||
},
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusNotFound {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.UpdateBackup(context.Background(), tt.dbUUID, tt.backupUUID, tt.request)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteBackup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
deleteS3 bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful delete with S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
deleteS3: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful delete without S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-2",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Contains(t, r.URL.RawQuery, fmt.Sprintf("delete_s3=%t", tt.deleteS3))
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Backup deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.DeleteBackup(context.Background(), tt.dbUUID, tt.backupUUID, tt.deleteS3)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_ListBackupExecutions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
serverResponse string
|
||||
statusCode int
|
||||
wantErr bool
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "successful list",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
serverResponse: `{
|
||||
"executions": [
|
||||
{
|
||||
"uuid": "exec-uuid-1",
|
||||
"filename": "backup.sql",
|
||||
"size": 1024,
|
||||
"status": "success",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list",
|
||||
dbUUID: "db-uuid-2",
|
||||
backupUUID: "backup-uuid-2",
|
||||
serverResponse: `{
|
||||
"executions": []
|
||||
}`,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "nonexistent",
|
||||
serverResponse: `{"error":"not found"}`,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID+"/executions", r.URL.Path)
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.serverResponse))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
executions, err := dbService.ListBackupExecutions(context.Background(), tt.dbUUID, tt.backupUUID)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, executions, tt.wantCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseService_DeleteBackupExecution(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dbUUID string
|
||||
backupUUID string
|
||||
executionUUID string
|
||||
deleteS3 bool
|
||||
statusCode int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "successful delete with S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "exec-uuid-1",
|
||||
deleteS3: true,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "successful delete without S3",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "exec-uuid-2",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusOK,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
dbUUID: "db-uuid-1",
|
||||
backupUUID: "backup-uuid-1",
|
||||
executionUUID: "nonexistent",
|
||||
deleteS3: false,
|
||||
statusCode: http.StatusNotFound,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/databases/"+tt.dbUUID+"/backups/"+tt.backupUUID+"/executions/"+tt.executionUUID, r.URL.Path)
|
||||
assert.Equal(t, http.MethodDelete, r.Method)
|
||||
assert.Contains(t, r.URL.RawQuery, fmt.Sprintf("delete_s3=%t", tt.deleteS3))
|
||||
w.WriteHeader(tt.statusCode)
|
||||
if tt.statusCode == http.StatusOK {
|
||||
w.Write([]byte(`{"message":"Backup execution deleted"}`))
|
||||
} else {
|
||||
w.Write([]byte(`{"error":"not found"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
dbService := NewDatabaseService(client)
|
||||
|
||||
err := dbService.DeleteBackupExecution(context.Background(), tt.dbUUID, tt.backupUUID, tt.executionUUID, tt.deleteS3)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DeploymentService handles deployment-related operations
|
||||
type DeploymentService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDeploymentService creates a new deployment service
|
||||
func NewDeploymentService(client *api.Client) *DeploymentService {
|
||||
return &DeploymentService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// DeploymentInfo represents a single deployment in the deploy response
|
||||
type DeploymentInfo struct {
|
||||
Message string `json:"message"`
|
||||
ResourceUUID string `json:"resource_uuid"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
}
|
||||
|
||||
// DeployResponse represents the response from a deploy operation
|
||||
type DeployResponse struct {
|
||||
Deployments []DeploymentInfo `json:"deployments"`
|
||||
}
|
||||
|
||||
// Deploy triggers a deployment for a resource
|
||||
func (s *DeploymentService) Deploy(ctx context.Context, uuid string, force bool) (*DeployResponse, error) {
|
||||
endpoint := fmt.Sprintf("deploy?uuid=%s", uuid)
|
||||
if force {
|
||||
endpoint += "&force=true"
|
||||
}
|
||||
|
||||
var response DeployResponse
|
||||
err := s.client.Get(ctx, endpoint, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deploy resource %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// List retrieves all deployments
|
||||
func (s *DeploymentService) List(ctx context.Context) ([]models.Deployment, error) {
|
||||
var deployments []models.Deployment
|
||||
err := s.client.Get(ctx, "deployments", &deployments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list deployments: %w", err)
|
||||
}
|
||||
return deployments, nil
|
||||
}
|
||||
|
||||
// Get retrieves a deployment by UUID
|
||||
func (s *DeploymentService) Get(ctx context.Context, uuid string) (*models.Deployment, error) {
|
||||
var deployment models.Deployment
|
||||
err := s.client.Get(ctx, fmt.Sprintf("deployments/%s", uuid), &deployment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get deployment %s: %w", uuid, err)
|
||||
}
|
||||
return &deployment, nil
|
||||
}
|
||||
|
||||
// CancelResponse represents the response from canceling a deployment
|
||||
type CancelResponse struct {
|
||||
Message string `json:"message"`
|
||||
DeploymentUUID string `json:"deployment_uuid"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Cancel cancels an in-progress deployment
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *DeploymentService) Cancel(ctx context.Context, uuid string) (*CancelResponse, error) {
|
||||
var response CancelResponse
|
||||
err := s.client.Post(ctx, fmt.Sprintf("deployments/%s/cancel", uuid), nil, &response)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to cancel deployment %s: %w", uuid, err)
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeploymentService_Deploy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
uuid string
|
||||
force bool
|
||||
expectedPath string
|
||||
response DeployResponse
|
||||
}{
|
||||
{
|
||||
name: "deploy without force",
|
||||
uuid: "res-123",
|
||||
force: false,
|
||||
expectedPath: "/api/v1/deploy?uuid=res-123",
|
||||
response: DeployResponse{
|
||||
Message: "Deployment started",
|
||||
DeploymentID: "dep-456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deploy with force",
|
||||
uuid: "res-789",
|
||||
force: true,
|
||||
expectedPath: "/api/v1/deploy?uuid=res-789&force=true",
|
||||
response: DeployResponse{
|
||||
Message: "Force deployment started",
|
||||
DeploymentID: "dep-999",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, tt.expectedPath, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(tt.response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDeploymentService(client)
|
||||
|
||||
result, err := svc.Deploy(context.Background(), tt.uuid, tt.force)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.response.Message, result.Message)
|
||||
assert.Equal(t, tt.response.DeploymentID, result.DeploymentID)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// DomainService handles domain-related operations
|
||||
type DomainService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewDomainService creates a new domain service
|
||||
func NewDomainService(client *api.Client) *DomainService {
|
||||
return &DomainService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all domains
|
||||
func (s *DomainService) List(ctx context.Context) ([]models.Domain, error) {
|
||||
var domains []models.Domain
|
||||
err := s.client.Get(ctx, "domains", &domains)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list domains: %w", err)
|
||||
}
|
||||
return domains, nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDomainService_List(t *testing.T) {
|
||||
domains := []models.Domain{
|
||||
{
|
||||
IP: "192.168.1.1",
|
||||
Domains: []string{"example.com", "www.example.com"},
|
||||
},
|
||||
{
|
||||
IP: "192.168.1.2",
|
||||
Domains: []string{"test.com"},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/domains", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(domains)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewDomainService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "192.168.1.1", result[0].IP)
|
||||
assert.Equal(t, []string{"example.com", "www.example.com"}, result[0].Domains)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// GitHubAppService handles GitHub App-related operations
|
||||
type GitHubAppService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewGitHubAppService creates a new GitHub App service
|
||||
func NewGitHubAppService(client *api.Client) *GitHubAppService {
|
||||
return &GitHubAppService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all GitHub Apps
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *GitHubAppService) List(ctx context.Context) ([]models.GitHubApp, error) {
|
||||
var apps []models.GitHubApp
|
||||
err := s.client.Get(ctx, "github-apps", &apps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list GitHub Apps: %w", err)
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific GitHub App by UUID
|
||||
// Note: This endpoint will be available in a future version of Coolify
|
||||
func (s *GitHubAppService) Get(ctx context.Context, uuid string) (*models.GitHubApp, error) {
|
||||
var app models.GitHubApp
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s", uuid), &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Create creates a new GitHub App
|
||||
func (s *GitHubAppService) Create(ctx context.Context, req *models.GitHubAppCreateRequest) (*models.GitHubApp, error) {
|
||||
var app models.GitHubApp
|
||||
err := s.client.Post(ctx, "github-apps", req, &app)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create GitHub App: %w", err)
|
||||
}
|
||||
return &app, nil
|
||||
}
|
||||
|
||||
// Update updates an existing GitHub App
|
||||
func (s *GitHubAppService) Update(ctx context.Context, uuid string, req *models.GitHubAppUpdateRequest) error {
|
||||
type response struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Patch(ctx, fmt.Sprintf("github-apps/%s", uuid), req, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete deletes a GitHub App
|
||||
func (s *GitHubAppService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("github-apps/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete GitHub App %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListRepositories lists all repositories accessible by a GitHub App
|
||||
func (s *GitHubAppService) ListRepositories(ctx context.Context, appUUID string) ([]models.GitHubRepository, error) {
|
||||
type response struct {
|
||||
Repositories []models.GitHubRepository `json:"repositories"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s/repositories", appUUID), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories for GitHub App %s: %w", appUUID, err)
|
||||
}
|
||||
return resp.Repositories, nil
|
||||
}
|
||||
|
||||
// ListBranches lists all branches for a repository
|
||||
func (s *GitHubAppService) ListBranches(ctx context.Context, appUUID string, owner, repo string) ([]models.GitHubBranch, error) {
|
||||
type response struct {
|
||||
Branches []models.GitHubBranch `json:"branches"`
|
||||
}
|
||||
var resp response
|
||||
err := s.client.Get(ctx, fmt.Sprintf("github-apps/%s/repositories/%s/%s/branches", appUUID, owner, repo), &resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list branches for %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
return resp.Branches, nil
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// PrivateKeyService handles private key-related operations
|
||||
type PrivateKeyService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewPrivateKeyService creates a new private key service
|
||||
func NewPrivateKeyService(client *api.Client) *PrivateKeyService {
|
||||
return &PrivateKeyService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all private keys
|
||||
func (s *PrivateKeyService) List(ctx context.Context) ([]models.PrivateKey, error) {
|
||||
var keys []models.PrivateKey
|
||||
err := s.client.Get(ctx, "security/keys", &keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list private keys: %w", err)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// Create creates a new private key
|
||||
func (s *PrivateKeyService) Create(ctx context.Context, req models.PrivateKeyCreateRequest) (*models.PrivateKey, error) {
|
||||
var key models.PrivateKey
|
||||
err := s.client.Post(ctx, "security/keys", req, &key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create private key: %w", err)
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// Delete deletes a private key by UUID
|
||||
func (s *PrivateKeyService) Delete(ctx context.Context, uuid string) error {
|
||||
err := s.client.Delete(ctx, fmt.Sprintf("security/keys/%s", uuid))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete private key %s: %w", uuid, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPrivateKeyService_List(t *testing.T) {
|
||||
keys := []models.PrivateKey{
|
||||
{
|
||||
UUID: "key-1",
|
||||
Name: "Test Key 1",
|
||||
},
|
||||
{
|
||||
UUID: "key-2",
|
||||
Name: "Test Key 2",
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys", r.URL.Path)
|
||||
assert.Equal(t, "GET", r.Method)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
result, err := svc.List(context.Background())
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 2)
|
||||
assert.Equal(t, "key-1", result[0].UUID)
|
||||
assert.Equal(t, "Test Key 1", result[0].Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyService_Create(t *testing.T) {
|
||||
req := models.PrivateKeyCreateRequest{
|
||||
Name: "New Key",
|
||||
PrivateKey: "ssh-rsa AAAAB3...",
|
||||
}
|
||||
|
||||
key := models.PrivateKey{
|
||||
UUID: "key-123",
|
||||
Name: req.Name,
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys", r.URL.Path)
|
||||
assert.Equal(t, "POST", r.Method)
|
||||
|
||||
var receivedReq models.PrivateKeyCreateRequest
|
||||
json.NewDecoder(r.Body).Decode(&receivedReq)
|
||||
assert.Equal(t, req.Name, receivedReq.Name)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(key)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
result, err := svc.Create(context.Background(), req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "key-123", result.UUID)
|
||||
assert.Equal(t, "New Key", result.Name)
|
||||
}
|
||||
|
||||
func TestPrivateKeyService_Delete(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/v1/security/keys/key-123", r.URL.Path)
|
||||
assert.Equal(t, "DELETE", r.Method)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := api.NewClient(server.URL, "test-token")
|
||||
svc := NewPrivateKeyService(client)
|
||||
|
||||
err := svc.Delete(context.Background(), "key-123")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coollabsio/coolify-cli/internal/api"
|
||||
"github.com/coollabsio/coolify-cli/internal/models"
|
||||
)
|
||||
|
||||
// ProjectService handles project-related operations
|
||||
type ProjectService struct {
|
||||
client *api.Client
|
||||
}
|
||||
|
||||
// NewProjectService creates a new project service
|
||||
func NewProjectService(client *api.Client) *ProjectService {
|
||||
return &ProjectService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// List retrieves all projects
|
||||
func (s *ProjectService) List(ctx context.Context) ([]models.Project, error) {
|
||||
var projects []models.Project
|
||||
err := s.client.Get(ctx, "projects", &projects)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list projects: %w", err)
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// Get retrieves a specific project by UUID
|
||||
func (s *ProjectService) Get(ctx context.Context, uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
err := s.client.Get(ctx, fmt.Sprintf("projects/%s", uuid), &project)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get project %s: %w", uuid, err)
|
||||
}
|
||||
return &project, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user