Compare commits

..

27 Commits

Author SHA1 Message Date
Laurence 9a128de992 enhance: fixes and update goreleaser to not set rc as latest 2025-04-21 09:59:20 +01:00
Laurence 6495804344 enhance: various enhancements and adding our own updater package 2025-04-20 13:42:59 +01:00
Laurence 3a994cb19e enhance: various enhancements and adding gocritic to improve codebase 2025-04-20 11:38:53 +01:00
Laurence 9b8992d177 enhance: private key fixes 2025-04-19 13:44:13 +01:00
Laurence de6418e532 enhance: Allow private key generation output to be optional and add a force if they want to overwrite on filesystem 2025-04-19 13:39:36 +01:00
Laurence 5e8c823637 enhance: Various enhancements 2025-04-19 13:20:07 +01:00
Laurence 7c370540e2 enhance: Updates and fix vagrant init password generator 2025-04-15 07:52:22 +01:00
Laurence 35f152b3d1 enhance: Switch over to client SDK codegen, note it current panics in servers list 2025-04-11 22:27:30 +01:00
Laurence 2b8a3bd120 enhance: Remove altscreen for now 2025-04-06 00:45:52 +01:00
Laurence 77a61d614e enhance: Fix filterabletable deletion and use filtertable in cliinstances 2025-04-06 00:29:10 +01:00
Laurence 255b918d02 enhance: Create filtertable reuseable component will expand to other commands 2025-04-05 22:58:50 +01:00
Laurence 200313c1b8 enhance: Expand private keys functions, Create pkg/tui which is a helper to generate branded terminal UI items 2025-04-05 19:58:42 +01:00
Laurence dd0d46b0fc enhance: Add vagrant file to automated setting up a local coolify for cli testing 2025-04-05 15:02:32 +01:00
Laurence 7c6a6b4292 wip: Start implemented privatekeys functionality (not tested) 2025-04-02 18:32:46 +01:00
Laurence ef4a847f10 wip: fix goreleaser title the os 2025-04-01 13:02:11 +01:00
Laurence b22f7b6943 wip: Rename repo from coolify-cli to cli-coolify 2025-04-01 12:56:42 +01:00
Laurence 9a4ef0d6ac wip: Fixes and general updates 2025-04-01 12:47:29 +01:00
Laurence 98a624af27 wip: more changes 2025-04-01 12:20:18 +01:00
Laurence cb185da557 wip: Model changed, Using text inputs provided by bubbles instead of computing it overselves 2025-04-01 09:33:20 +01:00
Laurence d809990bec wip: init is now pretty 2025-03-31 19:29:23 +01:00
Laurence f66c4f4217 wip: init now uses bubbletea 2025-03-31 19:18:58 +01:00
Laurence decc3e092a wip: readd the update command 2025-03-31 18:24:56 +01:00
Laurence 611b14d2ea wip: update list to use new table 2025-03-31 18:15:57 +01:00
Laurence d22e6607a9 wip: update cursorrules and vibe code 2025-03-31 17:37:52 +01:00
Laurence 1126defb7c wip: update cursorrules and vibe code 2025-03-31 17:37:30 +01:00
Laurence b4148d6344 wip 2025-03-23 18:30:41 +00:00
Laurence 8c38a5447a wip: started refactoring, need to work on implementing the rest of v0.0.1 commands but built a baseline 2025-03-22 17:50:46 +00:00
130 changed files with 20702 additions and 14777 deletions
-46
View File
@@ -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
View File
@@ -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
View File
@@ -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
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-343
View File
@@ -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
-141
View File
@@ -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
+71 -393
View File
@@ -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
-910
View File
@@ -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
},
}
-466
View File
@@ -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"))
}
+46
View File
@@ -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
}
+104
View File
@@ -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
}
+448
View File
@@ -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()
}
+128
View File
@@ -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()
}
+297
View File
@@ -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()
}
+48
View File
@@ -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
}
+204
View File
@@ -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
}
+51
View File
@@ -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
}
+26
View File
@@ -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
}
+59
View File
@@ -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
}
+29
View File
@@ -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
}
+439
View File
@@ -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
}
+266
View File
@@ -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
}
+203
View File
@@ -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
}
+32
View File
@@ -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
}
+70
View File
@@ -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
}
+319
View File
@@ -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
}
+163
View File
@@ -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
}
+216
View File
@@ -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
}
+66
View File
@@ -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
}
+36
View File
@@ -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
}
+151
View File
@@ -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)}
}
+84
View File
@@ -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
}
+33
View File
@@ -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
}
+10
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-357
View File
@@ -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
View File
@@ -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")
}
-49
View File
@@ -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)
}
+6
View File
@@ -0,0 +1,6 @@
package emoji
const (
CheckMarkButton = "\u2705" // ✅
CrossMark = "\u274c" // ❌
)
-393
View File
@@ -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)
}
-311
View File
@@ -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)
}
-132
View File
@@ -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
View File
@@ -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)
}
-53
View File
@@ -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
View File
@@ -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
}
+233
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
-177
View File
@@ -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
View File
@@ -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)
}
-57
View File
@@ -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)
}
+23
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
package utils
// Other utility functions can be added here
-19
View File
@@ -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)
}
-57
View File
@@ -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"
-7
View File
@@ -1,7 +0,0 @@
{
"scripts": {
"setup": "./conductor-setup.sh",
"run": "~/go/bin/air"
},
"runScriptMode": "nonconcurrent"
}
+58 -32
View File
@@ -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
+236 -78
View File
@@ -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=
-209
View File
@@ -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
}
-325
View File
@@ -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)
}
-66
View File
@@ -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
}
-37
View File
@@ -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
}
}
-193
View File
@@ -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")
}
-565
View File
@@ -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)
}
-36
View File
@@ -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
}
-91
View File
@@ -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
}
-132
View File
@@ -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"`
}
-19
View File
@@ -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:"-"`
}
-246
View File
@@ -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"`
}
-25
View File
@@ -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"`
}
-7
View File
@@ -1,7 +0,0 @@
package models
// Domain represents a domain configuration
type Domain struct {
IP string `json:"ip"`
Domains []string `json:"domains"`
}
-69
View File
@@ -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"`
}
-223
View File
@@ -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)
}
-16
View File
@@ -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"`
}
-35
View File
@@ -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"`
}
-22
View File
@@ -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"`
}
-28
View File
@@ -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"`
}
-70
View File
@@ -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"`
}
-22
View File
@@ -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:"-"`
}
-48
View File
@@ -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 = "********"
-267
View File
@@ -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")
}
-21
View File
@@ -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)
}
-22
View File
@@ -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)
}
-254
View File
@@ -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())
}
}
-108
View File
@@ -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
}
-229
View File
@@ -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
}
-199
View File
@@ -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
}
-800
View File
@@ -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")
}
-233
View File
@@ -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 ""
}
-858
View File
@@ -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
}
-86
View File
@@ -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
}
-65
View File
@@ -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)
})
}
}
-31
View File
@@ -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
}
-45
View File
@@ -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)
}
-101
View File
@@ -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
}
-50
View File
@@ -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
}
-94
View File
@@ -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)
}
-41
View File
@@ -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