19 Commits

Author SHA1 Message Date
Andras Bacsai 8ec750ecc6 Changes auto-committed by Conductor (#19) 2025-10-16 17:02:52 +02:00
Andras Bacsai 76396c3c06 Merge pull request #18 from coollabsio/andrasbacsai/review-install-script
Enhance install script with features and error handling
2025-10-16 14:56:58 +02:00
Andras Bacsai 3286229a06 Changes auto-committed by Conductor 2025-10-16 14:42:15 +02:00
Andras Bacsai 884b687947 Merge pull request #17 from coollabsio/andrasbacsai/api-endpoint-expansion
update README.md
2025-10-16 14:09:57 +02:00
Andras Bacsai 11f9baafc6 feat: expand CLI documentation with application, database, and service management commands 2025-10-16 14:09:20 +02:00
Andras Bacsai f4f628fdae Merge pull request #16 from coollabsio/andrasbacsai/api-endpoint-expansion
Refactor cli and use all available endpoints
2025-10-16 14:01:53 +02:00
Andras Bacsai ae086bbbbd Changes auto-committed by Conductor 2025-10-16 13:49:17 +02:00
Andras Bacsai 401f4bf317 Changes auto-committed by Conductor 2025-10-16 13:48:57 +02:00
Andras Bacsai 095b5a5bc5 Merge pull request #14 from coollabsio/add-testing-framework
docs: update CLAUDE.md with correct test commands
2025-10-14 23:26:08 +02:00
Andras Bacsai 002e206c54 feat: add air file watcher for hot reload during development
- Add .air.toml configuration for automatic rebuilds
- Update conductor.json run script to use air
- Update conductor-setup.sh to install air automatically
- Air watches .go files and rebuilds coolify binary on changes
- Excludes test files, vendor, and conductor directories from watch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:24:20 +02:00
Andras Bacsai c865aa7512 docs: update CLAUDE.md with correct test commands
- Update test commands to use ./internal/... path
- Add note about httptest.NewServer() for API mocking
- Clarify that tests never call real external APIs
- Update checklist to reflect internal/ test structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:16:22 +02:00
Andras Bacsai 3788d6b812 Merge pull request #13 from coollabsio/create-script
feat: add Conductor workspace configuration
2025-10-14 23:01:31 +02:00
Andras Bacsai 7d23eac444 feat: add Conductor workspace configuration
Add conductor.json and setup script to enable automated workspace setup:
- Auto-installs Go dependencies
- Validates Go version (1.24+)
- Builds coolify binary on workspace creation
- Configures test runner for development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 23:00:59 +02:00
Andras Bacsai 6aa77a4840 Merge pull request #12 from coollabsio/update-go-deps
chore: update Go dependencies to fix security vulnerabilities
2025-10-14 22:55:39 +02:00
Andras Bacsai 17f1435ce1 chore: update Go dependencies to fix security vulnerabilities
Updated key dependencies to address security vulnerabilities:
- golang.org/x/oauth2: v0.25.0 → v0.32.0 (High severity)
- golang.org/x/crypto: v0.32.0 → v0.43.0 (High severity DoS)
- github.com/ulikunitz/xz: v0.5.12 → v0.5.15 (Moderate memory leak)

Also updated:
- Go version: 1.22.0 → 1.24.6
- github.com/spf13/cobra: v1.8.1 → v1.10.1
- github.com/spf13/viper: v1.19.0 → v1.21.0
- 20+ other dependency updates

All tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 22:54:47 +02:00
Andras Bacsai 485b4c5f6b Merge pull request #9 from jizaymes/fix-update-text
fix cmd typo prompting user to update coolify
2025-10-14 22:50:15 +02:00
Andras Bacsai d930e6cf9f Merge pull request #11 from coollabsio/andrasbacsai/cli-restructure
feat: Complete CLI restructure with layered architecture
2025-10-14 22:49:15 +02:00
Andras Bacsai 519fe69ebf feat: complete CLI restructure with layered architecture
This commit completes a comprehensive restructuring of the Coolify CLI, implementing a clean layered architecture with improved maintainability, testability, and user experience.

## Major Changes

### Architecture Refactoring (Phases 1-3)
- Separated concerns into distinct layers: Commands → Services → API Client
- Created `internal/api/` - HTTP client with retry logic, error handling
- Created `internal/service/` - Business logic layer for all operations
- Created `internal/config/` - Multi-instance configuration management
- Created `internal/models/` - Shared data structures
- Created `internal/output/` - Pluggable formatters (table/json/pretty)

### New Features (Phase 4)
- **Named deployments**: `coolify deploy name <name>` - Deploy resources by name
- **Batch deployments**: `coolify deploy batch <name1,name2,...>` - Deploy multiple resources
- **Instance selection**: `--instance <name>` flag to use specific instances
- **Improved error messages**: Clean, actionable error output without usage clutter

### Testing & Documentation (Phase 5)
- **Test coverage**: 80%+ across all internal packages
- **Man pages**: Professional documentation (39 man pages)
- **Architecture docs**: Comprehensive ARCHITECTURE.md with diagrams
- **Practical examples**: Real-world scripts for CI/CD, multi-env deployments
- **Complete Godoc**: All public functions documented

## Benefits

### For Users
- More intuitive commands with consistent patterns
- Better error messages and debugging
- Multi-instance support for prod/staging workflows
- Professional documentation (man pages, examples)

### For Developers
- Clear separation of concerns
- Comprehensive test suite (80%+ coverage)
- Easy to add new commands/features
- Well-documented architecture

## Commands Enhanced

- `servers` - Refactored to use service layer
- `projects` - Improved with service pattern
- `deploy` - Added name-based and batch deployments
- `resources` - Refactored with cleaner code
- `domains` - Updated to new architecture
- `private-keys` - Improved with service layer
- `instances` - Enhanced configuration management

## New Commands

- `coolify deploy name <name>` - Deploy by resource name
- `coolify deploy batch <names>` - Deploy multiple resources
- `coolify docs man` - Generate man pages
- `coolify docs markdown` - Generate markdown documentation

## Files Added

### Core Architecture
- `internal/api/` - API client layer (4 files)
- `internal/service/` - Service layer (12 files)
- `internal/config/` - Configuration (4 files)
- `internal/models/` - Data models (8 files)
- `internal/output/` - Output formatters (5 files)

### Documentation
- `ARCHITECTURE.md` - Comprehensive architecture guide
- `CLAUDE.md` - Development instructions
- `examples/` - Practical usage examples

### Testing
- `test/fixtures/` - Test data
- `*_test.go` - Comprehensive test suites (80%+ coverage)

## Breaking Changes

None - All existing commands remain backward compatible.

## Migration Guide

No migration needed. All existing workflows continue to work.

New features are opt-in:
- Use `--instance` flag for multi-instance setups
- Use `deploy name` for name-based deployments
- Use `deploy batch` for multiple deployments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 22:29:11 +02:00
James Cornman 4e0575a156 update text prompting user to update coolify 2025-05-20 11:00:09 -05:00
78 changed files with 14015 additions and 997 deletions
+46
View File
@@ -0,0 +1,46 @@
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
+8 -1
View File
@@ -1,4 +1,11 @@
coolify-cli
coolify
cli
config.json
config.json
# Generated documentation (can be regenerated)
man/
docs/cli/
# Test coverage
coverage.out
+584
View File
@@ -0,0 +1,584 @@
# 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
@@ -0,0 +1,343 @@
# 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
@@ -0,0 +1,141 @@
# 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
+378 -3
View File
@@ -30,9 +30,10 @@ Now you can use the CLI with the token you just added.
## 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
@@ -43,5 +44,379 @@ You can change the default instance with `coolify instances set default <name>`
### Servers
- `coolify servers list` - List all servers
- `coolify servers get` - Get a server
- `--resources` - Get the resources and their status of a server
- `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
+910
View File
@@ -0,0 +1,910 @@
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
@@ -0,0 +1,466 @@
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"))
}
+1012
View File
File diff suppressed because it is too large Load Diff
+327 -49
View File
@@ -1,79 +1,357 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"strings"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
"github.com/spf13/cobra"
)
type Deploy struct {
Deployments []Deployment `json:"deployments"`
}
type Deployment struct {
Message string `json:"message"`
ResourceUuid string `json:"resource_uuid"`
DeploymentUuid string `json:"deployment_uuid"`
}
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: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
var CsvUuids = ""
for _, uuid := range args {
CsvUuids += uuid + ","
}
CsvUuids = CsvUuids[:len(CsvUuids)-1]
data, err := Fetch("deploy?uuid=" + CsvUuids)
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 {
log.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
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,
}
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Deploy
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
return formatter.Format(displays)
}
fmt.Fprintln(w, "Message\tResource Uuid\tDeployment Uuid")
for _, resource := range jsondata.Deployments {
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Message, resource.ResourceUuid, resource.DeploymentUuid)
}
w.Flush()
return formatter.Format(result)
},
}
// TODO deployByTagCmd
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
@@ -0,0 +1,93 @@
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")
}
+26 -45
View File
@@ -1,68 +1,49 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
"github.com/spf13/cobra"
)
type Domain struct {
IP string `json:"ip"`
Domains []string `json:"domains"`
}
var domainsCmd = &cobra.Command{
Use: "domains",
Short: "Domain related commands",
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
data, err := Fetch("domains")
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, err := getAPIClient(cmd)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Domain
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
fmt.Fprintln(w, "IP Address\tDomains")
for _, resource := range jsondata {
for _, domain := range resource.Domains {
fmt.Fprintf(w, "%s\t%s\n", resource.IP, domain)
}
domainSvc := service.NewDomainService(client)
domains, err := domainSvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list domains: %w", err)
}
w.Flush()
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)
rootCmd.AddCommand(domainsCmd)
domainsCmd.AddCommand(listDomainsCmd)
}
+393
View File
@@ -0,0 +1,393 @@
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)
}
+64 -59
View File
@@ -2,6 +2,7 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -9,26 +10,37 @@ import (
"github.com/spf13/viper"
)
var instancesCmd = &cobra.Command{
Use: "instances",
Short: "Coolify instance related commands.",
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 instanceVersionCmd = &cobra.Command{
var contextVersionCmd = &cobra.Command{
Use: "version",
Short: "Get instance version.",
Run: func(cmd *cobra.Command, args []string) {
data, err := Fetch("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 {
fmt.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
fmt.Println(data)
// 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 listInstancesCmd = &cobra.Command{
var listContextsCmd = &cobra.Command{
Use: "list",
Short: "List all Coolify instances.",
Short: "List all configured contexts",
Run: func(cmd *cobra.Command, args []string) {
instances := viper.Get("instances").([]interface{})
@@ -74,20 +86,21 @@ var listInstancesCmd = &cobra.Command{
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var addInstanceCmd = &cobra.Command{
Use: "add",
Example: `add <instanceName> <fqdn> <token>`,
Args: cobra.ExactArgs(3),
Short: "Add a Coolify instance.",
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 {
if force {
instanceMap["token"] = Token
if SetDefaultInstance {
for _, instance := range instances {
@@ -104,7 +117,7 @@ var addInstanceCmd = &cobra.Command{
return
}
fmt.Printf("%s already exists. \n", Name)
fmt.Println("\nNote: Use -f to force overwrite.")
fmt.Println("\nNote: Use --force to force overwrite.")
return
}
}
@@ -124,14 +137,14 @@ var addInstanceCmd = &cobra.Command{
}
viper.Set("instances", instances)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
listContextsCmd.Run(cmd, args)
},
}
var removeInstanceCmd = &cobra.Command{
Use: "remove",
Example: `remove <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Remove a Coolify instance.",
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]
@@ -158,18 +171,11 @@ var removeInstanceCmd = &cobra.Command{
fmt.Printf("%s not found. \n", Name)
},
}
var setCmd = &cobra.Command{
Use: "set",
Args: cobra.ExactArgs(2),
Short: "Set default instance or token.",
Run: func(cmd *cobra.Command, args []string) {
},
}
var setTokenCmd = &cobra.Command{
Use: "token",
Example: `set token <instanceName> "<token>"`,
Args: cobra.ExactArgs(2),
Short: "Set token for the given Coolify instance.",
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]
@@ -194,14 +200,14 @@ var setTokenCmd = &cobra.Command{
}
viper.Set("instances", instances)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
listContextsCmd.Run(cmd, args)
},
}
var setDefaultCmd = &cobra.Command{
Use: "default",
Example: `set default <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Set the default Coolify instance.",
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]
@@ -228,14 +234,14 @@ var setDefaultCmd = &cobra.Command{
}
viper.Set("instances", instances)
viper.WriteConfig()
listInstancesCmd.Run(cmd, args)
listContextsCmd.Run(cmd, args)
},
}
var getInstanceCmd = &cobra.Command{
Use: "get",
Example: `config get <instanceName>`,
Args: cobra.ExactArgs(1),
Short: "Get a Coolify instance.",
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]
@@ -291,16 +297,15 @@ var getInstanceCmd = &cobra.Command{
}
func init() {
addInstanceCmd.Flags().BoolVarP(&SetDefaultInstance, "default", "d", false, "Set default instance")
rootCmd.AddCommand(instancesCmd)
instancesCmd.AddCommand(instanceVersionCmd)
instancesCmd.AddCommand(listInstancesCmd)
instancesCmd.AddCommand(addInstanceCmd)
instancesCmd.AddCommand(removeInstanceCmd)
instancesCmd.AddCommand(setCmd)
instancesCmd.AddCommand(getInstanceCmd)
setCmd.AddCommand(setTokenCmd)
setCmd.AddCommand(setDefaultCmd)
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)
}
+65 -125
View File
@@ -1,192 +1,132 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"os"
"strings"
"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"
)
type PrivateKeys struct {
PrivateKeys []PrivateKey `json:"private_keys"`
}
type PrivateKey struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
PrivateKey string `json:"private_key"`
}
var privateKeysCmd = &cobra.Command{
Use: "private-keys",
Short: "Private key related commands",
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
baseUrl := "security/keys"
data, err := Fetch(baseUrl)
client, err := getAPIClient(cmd)
if err != nil {
log.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []PrivateKey
err = json.Unmarshal([]byte(data), &jsondata)
keySvc := service.NewPrivateKeyService(client)
keys, err := keySvc.List(ctx)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to list private keys: %w", err)
}
fmt.Fprintln(w, "Uuid\tName")
for _, resource := range jsondata {
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
} else {
fmt.Fprintf(w, "%s\t%s\n", resource.UUID, resource.Name)
}
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var onePrivateKeyCmd = &cobra.Command{
Use: "get [uuid]",
Args: cobra.ExactArgs(1),
Short: "Get private key details by uuid",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "security/keys/"
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
uuid := args[0]
var url = baseUrl + uuid
data, err := Fetch(url)
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
fmt.Println(err)
return
return err
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata PrivateKey
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tPublicKey\tPrivateKey")
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, jsondata.PublicKey, strings.ReplaceAll(jsondata.PrivateKey, "\n", "\\n"))
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay)
if err := formatter.Format(keys); err != nil {
return err
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
if !showSensitive && format == output.FormatTable {
fmt.Println("\nNote: Use -s to show sensitive information.")
}
return nil
},
}
var addPrivateKeyCmd = &cobra.Command{
Use: "add",
Example: `add <name> <private_key_or_file>`,
Args: cobra.ExactArgs(2),
Use: "add <name> <private_key_or_file>",
Example: `add mykey ~/.ssh/id_rsa`,
Args: exactArgs(2, "<uuid1> <uuid2>"),
Short: "Add a private key",
Run: func(cmd *cobra.Command, args []string) {
version := "4.0.0-beta.383"
CheckDefaultThings(&version)
baseUrl := "security/keys"
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 {
fmt.Printf("Error reading private key file: %v\n", err)
return
return fmt.Errorf("error reading private key file: %w", err)
}
privateKey = string(keyBytes)
} else {
privateKey = privateKeyInput
}
data := map[string]string{
"name": name,
"private_key": privateKey,
keySvc := service.NewPrivateKeyService(client)
req := models.PrivateKeyCreateRequest{
Name: name,
PrivateKey: privateKey,
}
jsonData, err := json.Marshal(data)
key, err := keySvc.Create(ctx, req)
if err != nil {
fmt.Printf("Error creating request: %v\n", err)
return
return fmt.Errorf("failed to add private key: %w", err)
}
_, err = Post(baseUrl, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Printf("Error adding private key: %v\n", err)
return
}
fmt.Printf("Private key '%s' added successfully\n", name)
fmt.Printf("Private key '%s' added successfully (UUID: %s)\n", key.Name, key.UUID)
return nil
},
}
var removePrivateKeyCmd = &cobra.Command{
Use: "remove [uuid]",
Args: cobra.ExactArgs(1),
Use: "remove <uuid>",
Args: exactArgs(1, "<uuid>"),
Short: "Remove a private key",
Run: func(cmd *cobra.Command, args []string) {
version := "4.0.0-beta.383"
CheckDefaultThings(&version)
baseUrl := "security/keys/"
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
uuid := args[0]
_, err := Delete(baseUrl + uuid)
client, err := getAPIClient(cmd)
if err != nil {
fmt.Println(err)
return
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(onePrivateKeyCmd)
privateKeysCmd.AddCommand(addPrivateKeyCmd)
privateKeysCmd.AddCommand(removePrivateKeyCmd)
}
+116 -167
View File
@@ -1,211 +1,160 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"strings"
"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"
)
type Application struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
}
type Environment struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Description *string `json:"description"`
Applications []Application `json:"applications"`
// EnvironmentRow represents an environment for display
type EnvironmentRow struct {
UUID string `json:"environment_uuid"`
EnvironmentName string `json:"environment_name"`
}
type Project struct {
Uuid string `json:"uuid"`
Name string `json:"name"`
Environments []Environment `json:"environments"`
// 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: "projects",
Short: "Project related commands",
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects"
data, err := Fetch(baseUrl)
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, err := getAPIClient(cmd)
if err != nil {
log.Println(err)
return
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Project
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
fmt.Fprintln(w, "Uuid\tName")
for _, resource := range jsondata {
fmt.Fprintf(w, "%s\t%s\n", resource.Uuid, resource.Name)
projectSvc := service.NewProjectService(client)
projects, err := projectSvc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list projects: %w", err)
}
w.Flush()
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: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
Args: exactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
uuid := args[0]
environment, _ := cmd.Flags().GetString("environment")
if environment != "" {
url := "projects/" + uuid + "/" + environment
data, err := Fetch(url)
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 {
log.Println(err)
return
return err
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Environment
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
log.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tStatus")
for _, resource := range jsondata.Applications {
fmt.Fprintf(w, "%s\t%s\t%s\n", resource.Uuid, resource.Name, resource.Status)
}
w.Flush()
return
return formatter.Format(project)
}
data, err := Fetch("projects/" + uuid)
// For table format, expand environments into separate rows
rows := expandProjectEnvironments(project)
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
log.Println(err)
return
return err
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata Project
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
log.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tEnvironments")
envNames := make([]string, len(jsondata.Environments))
for i, env := range jsondata.Environments {
envNames[i] = env.Name + " (" + env.Uuid + ")"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", jsondata.Uuid, jsondata.Name, strings.Join(envNames, ", "))
w.Flush()
},
}
var addProjectCmd = &cobra.Command{
Use: "add [name]",
Short: "Add a project",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects"
name := args[0]
data := map[string]string{
"name": name,
}
jsonData, err := json.Marshal(data)
if err != nil {
fmt.Println(err)
return
}
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println("Project added successfully with uuid " + msg["uuid"])
return formatter.Format(rows)
},
}
var removeProjectCmd = &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a project",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "projects/"
uuid := args[0]
response, err := Delete(baseUrl + uuid)
if err != nil {
fmt.Println(err)
return
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
},
// 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)
oneProjectCmd.Flags().StringP("environment", "e", "", "Environment")
projectsCmd.AddCommand(oneProjectCmd)
projectsCmd.AddCommand(addProjectCmd)
projectsCmd.AddCommand(removeProjectCmd)
}
+27 -32
View File
@@ -1,54 +1,49 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
"github.com/spf13/cobra"
)
var resourcesCmd = &cobra.Command{
Use: "resources",
Short: "Resource related commands",
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
data, err := Fetch("resources")
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, err := getAPIClient(cmd)
if err != nil {
log.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Resource
err = json.Unmarshal([]byte(data), &jsondata)
resourceSvc := service.NewResourceService(client)
resources, err := resourceSvc.List(ctx)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to list resources: %w", err)
}
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
for _, resource := range jsondata {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
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
}
w.Flush()
return formatter.Format(resources)
},
}
+184 -223
View File
@@ -11,37 +11,41 @@ import (
"text/tabwriter"
"time"
"github.com/adrg/xdg"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/config"
compareVersion "github.com/hashicorp/go-version"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var CliVersion = "0.0.1"
var LastUpdateCheckTime time.Time
var CheckInverval = 10 * time.Minute
// CliVersion is the CLI version
var CliVersion = "1.0.0"
var ConfigDir = xdg.ConfigHome
// CheckInterval for version checking
var CheckInterval = 10 * time.Minute
var Version string
var Name string
var Fqdn string
var Token string
var Instance http.Client
// SensitiveInformationOverlay is the string used to hide sensitive data
var SensitiveInformationOverlay = "********"
// Flags
var Debug bool
var ShowSensitive bool
var Force bool
var Format string
var JsonMode bool
var PrettyMode bool
var SetDefaultInstance bool
var w = tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', tabwriter.Debug)
// 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
)
// Tag represents a git tag for version checking
type Tag struct {
Ref string `json:"ref"`
}
@@ -50,167 +54,129 @@ 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 CheckFormat(format string) {
if format == "json" {
JsonMode = true
return
}
if format == "pretty" {
PrettyMode = true
return
}
if format == "table" {
return
}
fmt.Println("Invalid format", format)
os.Exit(0)
}
// 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")
func CheckDefaultThings(version *string) {
FetchVersion()
CheckFormat(Format)
if version == nil {
CheckMinimumVersion(Version)
// 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 {
CheckMinimumVersion(*version)
}
}
func CheckMinimumVersion(version string) {
requiredVersion, err := compareVersion.NewVersion(version)
if err != nil {
log.Println(err)
os.Exit(0)
}
currentVersion, err := compareVersion.NewVersion(Version)
if err != nil {
log.Println(err)
os.Exit(0)
}
if currentVersion.LessThan(requiredVersion) {
log.Printf("Minimum required Coolify API version is: %s\n", version)
log.Print("Please upgrade your Coolify instance for this command.\n")
os.Exit(0)
}
}
func FetchVersion() (string, error) {
data, err := Fetch("version")
if err != nil {
log.Println(err)
os.Exit(0)
return "", err
}
Version = data
return data, nil
}
func Fetch(url string) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Fetching data from", url)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
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))
}
return string(body), nil
}
func Post(url string, input io.Reader) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Posting data to", url)
}
req, err := http.NewRequest("POST", url, input)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
req.Header.Add("Content-Type", "application/json")
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode >= 400 {
message := string(body)
if message == "" {
message = "Unknown error"
} else {
msg := map[string]string{}
json.Unmarshal(body, &msg)
message = msg["message"]
instance, err = cfg.GetDefault()
if err != nil {
return nil, fmt.Errorf("no default instance configured: %w", err)
}
return "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
}
return string(body), nil
// 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 Delete(url string) (string, error) {
url = Fqdn + "/api/v1/" + url
if Debug {
log.Println("Deleting data from", url)
}
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return "", err
}
req.Header.Add("Authorization", "Bearer "+Token)
resp, err := Instance.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode != 200 {
message := string(body)
if message == "" {
message = "Unknown error"
} else {
msg := map[string]string{}
json.Unmarshal(body, &msg)
message = msg["message"]
// 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 "", fmt.Errorf("%s (rc: %d)", message, resp.StatusCode)
return nil
}
return string(body), nil
}
// 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) {
getLastUpdateCheckTime()
if LastUpdateCheckTime.Add(CheckInverval).After(time.Now()) {
if Debug {
log.Println("Skipping update check. Last check was less than 10 minutes ago.")
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
}
return CliVersion, nil
}
setLastUpdateCheckTime()
// Update check time
viper.Set("lastupdatechecktime", time.Now().Format(time.RFC3339))
viper.WriteConfig()
url := "https://api.github.com/repos/coollabsio/coolify-cli/git/refs/tags"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
@@ -254,13 +220,21 @@ func CheckLatestVersionOfCli() (string, error) {
}
sort.Sort(compareVersion.Collection(versions))
latestVersion := versions[len(versions)-1].String()
if latestVersion != CliVersion {
fmt.Printf("There is a new version of Coolify CLI available.\nPlease update with 'coolify --update'.\n\n")
}
return latestVersion, nil
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 {
@@ -271,82 +245,69 @@ func Execute() {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVarP(&Token, "token", "", "", "Token for authentication (https://app.coolify.io/security/api-tokens)")
rootCmd.PersistentFlags().StringVarP(&Fqdn, "host", "", "", "Coolify instance hostname")
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(&Force, "force", "f", false, "Force")
rootCmd.PersistentFlags().BoolVarP(&Debug, "debug", "", false, "Debug mode")
}
func setLastUpdateCheckTime() {
timeNow := time.Now()
viper.Set("lastupdatechecktime", timeNow)
viper.WriteConfig()
LastUpdateCheckTime = timeNow
}
func getLastUpdateCheckTime() {
lastUpdateCheckTimeString := viper.Get("lastupdatechecktime").(string)
lastUpdateCheckTime, err := time.Parse(time.RFC3339, lastUpdateCheckTimeString)
if err != nil {
log.Fatalf("Error parsing time: %v", err)
}
LastUpdateCheckTime = lastUpdateCheckTime
}
func initConfig() {
viper.SetConfigName("config")
viper.SetConfigType("json")
viper.AddConfigPath(ConfigDir + "/coolify")
if _, err := os.Stat(ConfigDir + "/coolify"); os.IsNotExist(err) {
os.MkdirAll(ConfigDir+"/coolify", 0755)
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", ConfigDir+"/coolify/config.json")
viper.Set("lastUpdateCheckTime", time.Now())
viper.Set("instances", []interface{}{map[string]interface{}{
"name": "cloud",
"default": true,
"fqdn": "https://app.coolify.io",
"token": "",
},
})
viper.Set("instances", append(viper.Get("instances").([]interface{}), map[string]interface{}{
"name": "localhost",
"fqdn": "http://localhost:8000",
"token": "",
}))
viper.SafeWriteConfig()
return
// Config file not found; ignore error if desired
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)
fmt.Println("Error reading config file:", err)
return
// Config file was found but another error was produced
}
}
if Debug {
log.Println("Using config file:", viper.ConfigFileUsed())
}
instancesMap := viper.Get("instances").([]interface{})
for _, instance := range instancesMap {
instanceMap := instance.(map[string]interface{})
if instanceMap["default"] == true {
if Fqdn == "" {
Fqdn = instanceMap["fqdn"].(string)
}
if Token == "" {
Token = instanceMap["token"].(string)
// 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)
}
}
}
}
data, err := CheckLatestVersionOfCli()
if err != nil {
log.Println(err)
}
if data != CliVersion {
log.Printf("New version of Coolify CLI is available: %s\n", data)
}
}
+169 -165
View File
@@ -1,234 +1,238 @@
package cmd
import (
"bytes"
"encoding/json"
"context"
"fmt"
"log"
"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
type Resource struct {
ID int `json:"id"`
Uuid string `json:"uuid"`
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
}
type Resources struct {
Resources []Resource `json:"resources"`
}
type Server struct {
ID int `json:"id"`
UUID string `json:"uuid"`
Name string `json:"name"`
IP string `json:"ip"`
User string `json:"user"`
Port int `json:"port"`
Settings struct {
Reachable bool `json:"is_reachable"`
Usable bool `json:"is_usable"`
} `json:"settings"`
}
var serversCmd = &cobra.Command{
Use: "servers",
Short: "Server related commands",
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
baseUrl := "servers"
data, err := Fetch(baseUrl)
// Get API client
client, err := getAPIClient(cmd)
if err != nil {
log.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
var jsondata []Server
err = json.Unmarshal([]byte(data), &jsondata)
// Check API version
version, err := client.GetVersion(ctx)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to get API version: %w", err)
}
Version = version
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
for _, resource := range jsondata {
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", resource.UUID, resource.Name, resource.IP, resource.User, resource.Port, resource.Settings.Reachable, resource.Settings.Usable)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", resource.UUID, resource.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, resource.Settings.Reachable, resource.Settings.Usable)
}
}
w.Flush()
fmt.Println("\nNote: Use -s to show sensitive information.")
},
}
var oneServerCmd = &cobra.Command{
Use: "get [uuid]",
Args: cobra.ExactArgs(1),
Short: "Get server details by uuid",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
var url = baseUrl + uuid
if WithResources {
url = baseUrl + uuid + "?resources=true"
}
data, err := Fetch(url)
// Use service layer
serverSvc := service.NewServerService(client)
servers, err := serverSvc.List(ctx)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to list servers: %w", err)
}
if PrettyMode {
var prettyJSON bytes.Buffer
err := json.Indent(&prettyJSON, []byte(data), "", "\t")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(prettyJSON.String()))
return
}
if JsonMode {
fmt.Println(data)
return
}
if WithResources {
var jsondata Resources
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tType\tStatus")
for _, resource := range jsondata.Resources {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t\n", resource.Uuid, resource.Name, resource.Type, resource.Status)
}
w.Flush()
} else {
var jsondata Server
err = json.Unmarshal([]byte(data), &jsondata)
if err != nil {
fmt.Println(err)
return
}
fmt.Fprintln(w, "Uuid\tName\tIP Address\tUser\tPort\tReachable\tUsable")
if ShowSensitive {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%t\t%t\n", jsondata.UUID, jsondata.Name, jsondata.IP, jsondata.User, jsondata.Port, jsondata.Settings.Reachable, jsondata.Settings.Usable)
} else {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%t\t%t\n", jsondata.UUID, jsondata.Name, SensitiveInformationOverlay, SensitiveInformationOverlay, SensitiveInformationOverlay, jsondata.Settings.Reachable, jsondata.Settings.Usable)
}
w.Flush()
// 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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
response, err := Delete(baseUrl + uuid)
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Get API client
client, err := getAPIClient(cmd)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
// 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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "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)
}
// 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")
jsonData, err := json.Marshal(map[string]interface{}{
"name": name,
"ip": ip,
"port": port,
"user": user,
"private_key_uuid": privateKeyUuid,
"instant_validate": validate,
})
if err != nil {
fmt.Println(err)
return
// Create request
req := models.ServerCreateRequest{
Name: name,
IP: ip,
Port: port,
User: user,
PrivateKeyUUID: privateKeyUuid,
InstantValidate: validate,
}
response, err := Post(baseUrl, bytes.NewBuffer(jsonData))
// Use service layer
serverSvc := service.NewServerService(client)
response, err := serverSvc.Create(ctx, req)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to create server: %w", err)
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
if validate {
fmt.Println("Server added successfully with uuid " + msg["uuid"])
fmt.Printf("Server added successfully with uuid %s\n", response.UUID)
} else {
fmt.Println("Server added successfully with uuid " + msg["uuid"] + ". Server is not validated. Use 'servers validate " + msg["uuid"] + "' to validate the server.")
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",
Run: func(cmd *cobra.Command, args []string) {
CheckDefaultThings(nil)
baseUrl := "servers/"
uuid := args[0]
var url = baseUrl + uuid + "/validate"
response, err := Fetch(url)
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
// Get API client
client, err := getAPIClient(cmd)
if err != nil {
fmt.Println(err)
return
return fmt.Errorf("failed to get API client: %w", err)
}
msg := map[string]string{}
json.Unmarshal([]byte(response), &msg)
fmt.Println(msg["message"])
// 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)
+629
View File
@@ -0,0 +1,629 @@
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
@@ -0,0 +1,177 @@
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
@@ -0,0 +1,171 @@
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
@@ -0,0 +1,57 @@
#!/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
@@ -0,0 +1,7 @@
{
"scripts": {
"setup": "./conductor-setup.sh",
"run": "~/go/bin/air"
},
"runScriptMode": "nonconcurrent"
}
+28 -30
View File
@@ -1,49 +1,47 @@
module github.com/coollabsio/coolify-cli
go 1.22.0
toolchain go1.23.2
go 1.24.6
require (
github.com/adrg/xdg v0.5.3
github.com/creativeprojects/go-selfupdate v1.4.0
github.com/creativeprojects/go-selfupdate v1.5.1
github.com/hashicorp/go-version v1.7.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
require (
code.gitea.io/sdk/gitea v0.20.0 // indirect
github.com/42wim/httpsig v1.2.2 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // indirect
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/fsnotify/fsnotify v1.8.0 // 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/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // 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/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.12 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/go-gitlab v0.115.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.9.0 // indirect
gopkg.in/ini.v1 v1.67.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+51 -60
View File
@@ -1,15 +1,16 @@
code.gitea.io/sdk/gitea v0.20.0 h1:Zm/QDwwZK1awoM4AxdjeAQbxolzx2rIP8dDfmKu+KoU=
code.gitea.io/sdk/gitea v0.20.0/go.mod h1:faouBHC/zyx5wLgjmRKR62ydyvMzwWf3QnU0bH7Cw6U=
github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA=
github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
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/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creativeprojects/go-selfupdate v1.4.0 h1:4ePPd2CPCNl/YoPXeVxpuBLDUZh8rMEKP5ac+1Y/r5c=
github.com/creativeprojects/go-selfupdate v1.4.0/go.mod h1:oPG7LmzEmS6OxfqEm620k5VKxP45xFZNKMkp4V5qqUY=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
@@ -18,10 +19,12 @@ 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/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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -35,93 +38,81 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
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.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
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/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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/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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
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/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
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.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
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/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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
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.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
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/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.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
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/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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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=
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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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
@@ -0,0 +1,209 @@
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
@@ -0,0 +1,325 @@
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
@@ -0,0 +1,66 @@
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
@@ -0,0 +1,37 @@
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
@@ -0,0 +1,193 @@
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
@@ -0,0 +1,565 @@
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
@@ -0,0 +1,36 @@
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
@@ -0,0 +1,91 @@
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
@@ -0,0 +1,132 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,246 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,7 @@
package models
// Domain represents a domain configuration
type Domain struct {
IP string `json:"ip"`
Domains []string `json:"domains"`
}
+69
View File
@@ -0,0 +1,69 @@
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
@@ -0,0 +1,223 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,28 @@
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
@@ -0,0 +1,70 @@
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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,267 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,254 @@
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
@@ -0,0 +1,108 @@
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
@@ -0,0 +1,229 @@
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
@@ -0,0 +1,199 @@
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
@@ -0,0 +1,800 @@
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
@@ -0,0 +1,233 @@
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
@@ -0,0 +1,858 @@
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
@@ -0,0 +1,86 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,31 @@
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
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,101 @@
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
@@ -0,0 +1,50 @@
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
@@ -0,0 +1,94 @@
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
@@ -0,0 +1,41 @@
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
}
+75
View File
@@ -0,0 +1,75 @@
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 TestProjectService_List(t *testing.T) {
desc1 := "Description 1"
desc2 := "Description 2"
projects := []models.Project{
{
UUID: "proj-1",
Name: "Test Project 1",
Description: &desc1,
},
{
UUID: "proj-2",
Name: "Test Project 2",
Description: &desc2,
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/projects", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(projects)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewProjectService(client)
result, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "proj-1", result[0].UUID)
assert.Equal(t, "Test Project 1", result[0].Name)
}
func TestProjectService_Get(t *testing.T) {
desc := "Test Description"
project := models.Project{
UUID: "proj-1",
Name: "Test Project",
Description: &desc,
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/projects/proj-1", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(project)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewProjectService(client)
result, err := svc.Get(context.Background(), "proj-1")
require.NoError(t, err)
assert.Equal(t, "proj-1", result.UUID)
assert.Equal(t, "Test Project", result.Name)
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
// ResourceService handles resource-related operations
type ResourceService struct {
client *api.Client
}
// NewResourceService creates a new resource service
func NewResourceService(client *api.Client) *ResourceService {
return &ResourceService{
client: client,
}
}
// List retrieves all resources
func (s *ResourceService) List(ctx context.Context) ([]models.Resource, error) {
var resources []models.Resource
err := s.client.Get(ctx, "resources", &resources)
if err != nil {
return nil, fmt.Errorf("failed to list resources: %w", err)
}
return resources, nil
}
+47
View File
@@ -0,0 +1,47 @@
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 TestResourceService_List(t *testing.T) {
resources := []models.Resource{
{
UUID: "res-1",
Name: "Test Resource 1",
Type: "application",
},
{
UUID: "res-2",
Name: "Test Resource 2",
Type: "database",
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/resources", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resources)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewResourceService(client)
result, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "res-1", result[0].UUID)
assert.Equal(t, "Test Resource 1", result[0].Name)
}
+58
View File
@@ -0,0 +1,58 @@
package service
import (
"context"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
// ServerService handles server-related operations
type ServerService struct {
client *api.Client
}
// NewServerService creates a new server service
func NewServerService(client *api.Client) *ServerService {
return &ServerService{client: client}
}
// List returns all servers
func (s *ServerService) List(ctx context.Context) ([]models.Server, error) {
var servers []models.Server
err := s.client.Get(ctx, "servers", &servers)
return servers, err
}
// Get returns a single server by UUID
func (s *ServerService) Get(ctx context.Context, uuid string) (*models.Server, error) {
var server models.Server
err := s.client.Get(ctx, "servers/"+uuid, &server)
return &server, err
}
// GetResources returns resources for a server
func (s *ServerService) GetResources(ctx context.Context, uuid string) (*models.Resources, error) {
var resources models.Resources
err := s.client.Get(ctx, "servers/"+uuid+"?resources=true", &resources)
return &resources, err
}
// Create creates a new server
func (s *ServerService) Create(ctx context.Context, req models.ServerCreateRequest) (*models.Response, error) {
var response models.Response
err := s.client.Post(ctx, "servers", req, &response)
return &response, err
}
// Delete deletes a server by UUID
func (s *ServerService) Delete(ctx context.Context, uuid string) error {
return s.client.Delete(ctx, "servers/"+uuid)
}
// Validate validates a server by UUID
func (s *ServerService) Validate(ctx context.Context, uuid string) (*models.Response, error) {
var response models.Response
err := s.client.Get(ctx, "servers/"+uuid+"/validate", &response)
return &response, err
}
+158
View File
@@ -0,0 +1,158 @@
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 TestServerService_List(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers", r.URL.Path)
assert.Equal(t, "GET", r.Method)
servers := []models.Server{
{UUID: "uuid-1", Name: "server-1"},
{UUID: "uuid-2", Name: "server-2"},
}
json.NewEncoder(w).Encode(servers)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
servers, err := svc.List(context.Background())
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 TestServerService_Get(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
assert.Equal(t, "GET", r.Method)
server := models.Server{
UUID: "test-uuid",
Name: "test-server",
IP: "192.168.1.100",
}
json.NewEncoder(w).Encode(server)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
result, err := svc.Get(context.Background(), "test-uuid")
require.NoError(t, err)
assert.Equal(t, "test-uuid", result.UUID)
assert.Equal(t, "test-server", result.Name)
assert.Equal(t, "192.168.1.100", result.IP)
}
func TestServerService_GetResources(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
assert.Equal(t, "resources=true", r.URL.RawQuery)
assert.Equal(t, "GET", r.Method)
resources := models.Resources{
Resources: []models.Resource{
{UUID: "res-1", Name: "resource-1", Type: "application"},
},
}
json.NewEncoder(w).Encode(resources)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
result, err := svc.GetResources(context.Background(), "test-uuid")
require.NoError(t, err)
assert.Len(t, result.Resources, 1)
assert.Equal(t, "res-1", result.Resources[0].UUID)
}
func TestServerService_Create(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.ServerCreateRequest
json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "new-server", req.Name)
assert.Equal(t, "192.168.1.200", req.IP)
response := models.Response{Message: "Server created"}
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
req := models.ServerCreateRequest{
Name: "new-server",
IP: "192.168.1.200",
Port: 22,
User: "root",
PrivateKeyUUID: "key-uuid",
}
result, err := svc.Create(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, "Server created", result.Message)
}
func TestServerService_Delete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers/test-uuid", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(models.Response{Message: "Server deleted"})
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
err := svc.Delete(context.Background(), "test-uuid")
require.NoError(t, err)
}
func TestServerService_Validate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/servers/test-uuid/validate", r.URL.Path)
assert.Equal(t, "GET", r.Method)
response := models.Response{Message: "Server is valid"}
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServerService(client)
result, err := svc.Validate(context.Background(), "test-uuid")
require.NoError(t, err)
assert.Equal(t, "Server is valid", result.Message)
}
+167
View File
@@ -0,0 +1,167 @@
package service
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
// ServiceService handles service-related operations
type ServiceService struct {
client *api.Client
}
// NewServiceService creates a new service service instance
func NewServiceService(client *api.Client) *ServiceService {
return &ServiceService{client: client}
}
// List retrieves all services
func (s *ServiceService) List(ctx context.Context) ([]models.Service, error) {
var services []models.Service
err := s.client.Get(ctx, "services", &services)
if err != nil {
return nil, fmt.Errorf("failed to list services: %w", err)
}
return services, nil
}
// Get retrieves a service by UUID
func (s *ServiceService) Get(ctx context.Context, uuid string) (*models.Service, error) {
var service models.Service
err := s.client.Get(ctx, fmt.Sprintf("services/%s", uuid), &service)
if err != nil {
return nil, fmt.Errorf("failed to get service %s: %w", uuid, err)
}
return &service, nil
}
// Create creates a new service
func (s *ServiceService) Create(ctx context.Context, req *models.ServiceCreateRequest) (*models.Service, error) {
var service models.Service
err := s.client.Post(ctx, "services", req, &service)
if err != nil {
return nil, fmt.Errorf("failed to create service: %w", err)
}
return &service, nil
}
// Update updates a service
func (s *ServiceService) Update(ctx context.Context, uuid string, req *models.ServiceUpdateRequest) (*models.Service, error) {
var service models.Service
err := s.client.Patch(ctx, fmt.Sprintf("services/%s", uuid), req, &service)
if err != nil {
return nil, fmt.Errorf("failed to update service %s: %w", uuid, err)
}
return &service, nil
}
// Delete deletes a service
func (s *ServiceService) Delete(ctx context.Context, uuid string, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks bool) error {
url := fmt.Sprintf("services/%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 service %s: %w", uuid, err)
}
return nil
}
// Start starts a service
func (s *ServiceService) Start(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
var resp models.ServiceLifecycleResponse
err := s.client.Post(ctx, fmt.Sprintf("services/%s/start", uuid), nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to start service %s: %w", uuid, err)
}
return &resp, nil
}
// Stop stops a service
func (s *ServiceService) Stop(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
var resp models.ServiceLifecycleResponse
err := s.client.Post(ctx, fmt.Sprintf("services/%s/stop", uuid), nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to stop service %s: %w", uuid, err)
}
return &resp, nil
}
// Restart restarts a service
func (s *ServiceService) Restart(ctx context.Context, uuid string) (*models.ServiceLifecycleResponse, error) {
var resp models.ServiceLifecycleResponse
err := s.client.Post(ctx, fmt.Sprintf("services/%s/restart", uuid), nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to restart service %s: %w", uuid, err)
}
return &resp, nil
}
// ListEnvs retrieves all environment variables for a service
func (s *ServiceService) ListEnvs(ctx context.Context, uuid string) ([]models.EnvironmentVariable, error) {
var envs []models.EnvironmentVariable
err := s.client.Get(ctx, fmt.Sprintf("services/%s/envs", uuid), &envs)
if err != nil {
return nil, fmt.Errorf("failed to list environment variables for service %s: %w", uuid, err)
}
return envs, nil
}
// GetEnv retrieves a single environment variable by UUID or key
func (s *ServiceService) GetEnv(ctx context.Context, serviceUUID, envIdentifier string) (*models.EnvironmentVariable, error) {
envs, err := s.ListEnvs(ctx, serviceUUID)
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 service %s", envIdentifier, serviceUUID)
}
// CreateEnv creates a new environment variable for a service
func (s *ServiceService) CreateEnv(ctx context.Context, uuid string, req *models.EnvironmentVariableCreateRequest) (*models.EnvironmentVariable, error) {
var env models.EnvironmentVariable
err := s.client.Post(ctx, fmt.Sprintf("services/%s/envs", uuid), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to create environment variable for service %s: %w", uuid, err)
}
return &env, nil
}
// UpdateEnv updates an environment variable for a service
func (s *ServiceService) UpdateEnv(ctx context.Context, serviceUUID string, req *models.EnvironmentVariableUpdateRequest) (*models.EnvironmentVariable, error) {
var env models.EnvironmentVariable
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/envs", serviceUUID), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to update environment variable for service %s: %w", serviceUUID, err)
}
return &env, nil
}
// DeleteEnv deletes an environment variable from a service
func (s *ServiceService) DeleteEnv(ctx context.Context, serviceUUID, envUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("services/%s/envs/%s", serviceUUID, envUUID))
if err != nil {
return fmt.Errorf("failed to delete environment variable %s from service %s: %w", envUUID, serviceUUID, err)
}
return nil
}
// BulkUpdateEnvs updates multiple environment variables in a single request
func (s *ServiceService) BulkUpdateEnvs(ctx context.Context, serviceUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
var response BulkUpdateEnvsResponse
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/envs/bulk", serviceUUID), req, &response)
if err != nil {
return nil, fmt.Errorf("failed to bulk update environment variables for service %s: %w", serviceUUID, err)
}
return &response, nil
}
+355
View File
@@ -0,0 +1,355 @@
package service
import (
"context"
"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 TestServiceService_List(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[
{
"id": 1,
"uuid": "service-uuid-1",
"name": "PostgreSQL",
"status": "running",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
},
{
"id": 2,
"uuid": "service-uuid-2",
"name": "Redis",
"status": "stopped",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z"
}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
services, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, services, 2)
assert.Equal(t, "service-uuid-1", services[0].UUID)
assert.Equal(t, "PostgreSQL", services[0].Name)
assert.Equal(t, "running", services[0].Status)
assert.Equal(t, "service-uuid-2", services[1].UUID)
assert.Equal(t, "Redis", services[1].Name)
assert.Equal(t, "stopped", services[1].Status)
}
func TestServiceService_List_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
services, err := svc.List(context.Background())
require.NoError(t, err)
assert.Len(t, services, 0)
}
func TestServiceService_List_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "internal server error"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
_, err := svc.List(context.Background())
require.Error(t, err)
}
func TestServiceService_Get(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"id": 1,
"uuid": "service-uuid-123",
"name": "PostgreSQL 16",
"description": "Production database",
"status": "running",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"databases": [
{
"id": 10,
"uuid": "db-uuid-1",
"name": "main_db",
"type": "postgresql"
}
]
}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
service, err := svc.Get(context.Background(), "service-uuid-123")
require.NoError(t, err)
assert.Equal(t, "service-uuid-123", service.UUID)
assert.Equal(t, "PostgreSQL 16", service.Name)
assert.Equal(t, "running", service.Status)
assert.NotNil(t, service.Description)
assert.Equal(t, "Production database", *service.Description)
assert.Len(t, service.Databases, 1)
assert.Equal(t, "db-uuid-1", service.Databases[0].UUID)
}
func TestServiceService_Get_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error": "service not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
_, err := svc.Get(context.Background(), "nonexistent")
require.Error(t, err)
}
func TestServiceService_Start(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/start", r.URL.Path)
assert.Equal(t, "POST", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "Service starting request queued."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
resp, err := svc.Start(context.Background(), "service-uuid-123")
require.NoError(t, err)
assert.Equal(t, "Service starting request queued.", resp.Message)
}
func TestServiceService_Start_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error": "service already running"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
_, err := svc.Start(context.Background(), "service-uuid-123")
require.Error(t, err)
}
func TestServiceService_Stop(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/stop", r.URL.Path)
assert.Equal(t, "POST", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "Service stopping request queued."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
resp, err := svc.Stop(context.Background(), "service-uuid-123")
require.NoError(t, err)
assert.Equal(t, "Service stopping request queued.", resp.Message)
}
func TestServiceService_Stop_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error": "service already stopped"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
_, err := svc.Stop(context.Background(), "service-uuid-123")
require.Error(t, err)
}
func TestServiceService_Restart(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/restart", r.URL.Path)
assert.Equal(t, "POST", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "Service restarting request queued."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
resp, err := svc.Restart(context.Background(), "service-uuid-123")
require.NoError(t, err)
assert.Equal(t, "Service restarting request queued.", resp.Message)
}
func TestServiceService_Restart_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"error": "service not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
_, err := svc.Restart(context.Background(), "service-uuid-123")
require.Error(t, err)
}
func TestServiceService_ListEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`[
{
"uuid": "env-1",
"key": "DATABASE_URL",
"value": "postgres://localhost",
"is_build_time": false,
"is_preview": false
},
{
"uuid": "env-2",
"key": "API_KEY",
"value": "secret",
"is_build_time": true,
"is_preview": false
}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
envs, err := svc.ListEnvs(context.Background(), "service-uuid-123")
require.NoError(t, err)
assert.Len(t, envs, 2)
assert.Equal(t, "DATABASE_URL", envs[0].Key)
assert.Equal(t, "API_KEY", envs[1].Key)
}
func TestServiceService_CreateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
assert.Equal(t, "POST", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"uuid": "env-new",
"key": "NEW_VAR",
"value": "new_value",
"is_build_time": false,
"is_preview": false
}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
env, err := svc.CreateEnv(context.Background(), "service-uuid-123", &models.EnvironmentVariableCreateRequest{
Key: "NEW_VAR",
Value: "new_value",
})
require.NoError(t, err)
assert.Equal(t, "NEW_VAR", env.Key)
assert.Equal(t, "new_value", env.Value)
}
func TestServiceService_UpdateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/envs", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"uuid": "env-123",
"key": "UPDATED_VAR",
"value": "updated_value",
"is_build_time": true,
"is_preview": false
}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
newKey := "UPDATED_VAR"
env, err := svc.UpdateEnv(context.Background(), "service-uuid-123", &models.EnvironmentVariableUpdateRequest{
UUID: "env-123",
Key: &newKey,
})
require.NoError(t, err)
assert.Equal(t, "UPDATED_VAR", env.Key)
}
func TestServiceService_DeleteEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/service-uuid-123/envs/env-456", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewServiceService(client)
err := svc.DeleteEnv(context.Background(), "service-uuid-123", "env-456")
require.NoError(t, err)
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
// TeamService handles team-related operations
type TeamService struct {
client *api.Client
}
// NewTeamService creates a new team service
func NewTeamService(client *api.Client) *TeamService {
return &TeamService{client: client}
}
// List retrieves all teams
func (s *TeamService) List(ctx context.Context) ([]models.Team, error) {
var teams []models.Team
err := s.client.Get(ctx, "teams", &teams)
if err != nil {
return nil, fmt.Errorf("failed to list teams: %w", err)
}
return teams, nil
}
// Get retrieves a team by ID
func (s *TeamService) Get(ctx context.Context, id string) (*models.Team, error) {
var team models.Team
err := s.client.Get(ctx, fmt.Sprintf("teams/%s", id), &team)
if err != nil {
return nil, fmt.Errorf("failed to get team %s: %w", id, err)
}
return &team, nil
}
// Current retrieves the currently authenticated team
func (s *TeamService) Current(ctx context.Context) (*models.Team, error) {
var team models.Team
err := s.client.Get(ctx, "teams/current", &team)
if err != nil {
return nil, fmt.Errorf("failed to get current team: %w", err)
}
return &team, nil
}
// ListMembers retrieves members of a specific team
func (s *TeamService) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
var members []models.TeamMember
err := s.client.Get(ctx, fmt.Sprintf("teams/%s/members", teamID), &members)
if err != nil {
return nil, fmt.Errorf("failed to list members for team %s: %w", teamID, err)
}
return members, nil
}
// CurrentMembers retrieves members of the currently authenticated team
func (s *TeamService) CurrentMembers(ctx context.Context) ([]models.TeamMember, error) {
var members []models.TeamMember
err := s.client.Get(ctx, "teams/current/members", &members)
if err != nil {
return nil, fmt.Errorf("failed to list current team members: %w", err)
}
return members, nil
}
+292
View File
@@ -0,0 +1,292 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/coollabsio/coolify-cli/internal/api"
)
func TestTeamService_List(t *testing.T) {
tests := []struct {
name string
response string
statusCode int
wantErr bool
wantCount int
}{
{
name: "successful list",
response: `[
{"uuid": "team-1", "name": "Team 1", "description": "First team"},
{"uuid": "team-2", "name": "Team 2", "description": "Second team"}
]`,
statusCode: http.StatusOK,
wantErr: false,
wantCount: 2,
},
{
name: "empty list",
response: `[]`,
statusCode: http.StatusOK,
wantErr: false,
wantCount: 0,
},
{
name: "server error",
response: `{"error": "internal server error"}`,
statusCode: http.StatusInternalServerError,
wantErr: true,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/teams" {
t.Errorf("Expected path /api/v1/teams, got %s", r.URL.Path)
}
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewTeamService(client)
teams, err := svc.List(context.Background())
if (err != nil) != tt.wantErr {
t.Errorf("TeamService.List() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(teams) != tt.wantCount {
t.Errorf("TeamService.List() got %d teams, want %d", len(teams), tt.wantCount)
}
})
}
}
func TestTeamService_Get(t *testing.T) {
tests := []struct {
name string
teamID string
response string
statusCode int
wantErr bool
}{
{
name: "successful get",
teamID: "team-123",
response: `{"uuid": "team-123", "name": "Test Team", "description": "A test team"}`,
statusCode: http.StatusOK,
wantErr: false,
},
{
name: "not found",
teamID: "nonexistent",
response: `{"error": "team 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) {
expectedPath := "/api/v1/teams/" + tt.teamID
if r.URL.Path != expectedPath {
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
}
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewTeamService(client)
team, err := svc.Get(context.Background(), tt.teamID)
if (err != nil) != tt.wantErr {
t.Errorf("TeamService.Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && team.UUID != tt.teamID {
t.Errorf("TeamService.Get() got UUID %s, want %s", team.UUID, tt.teamID)
}
})
}
}
func TestTeamService_Current(t *testing.T) {
tests := []struct {
name string
response string
statusCode int
wantErr bool
wantName string
}{
{
name: "successful get current",
response: `{"uuid": "current-team", "name": "Current Team", "description": "The current team"}`,
statusCode: http.StatusOK,
wantErr: false,
wantName: "Current Team",
},
{
name: "unauthorized",
response: `{"error": "unauthorized"}`,
statusCode: http.StatusUnauthorized,
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) {
if r.URL.Path != "/api/v1/teams/current" {
t.Errorf("Expected path /api/v1/teams/current, got %s", r.URL.Path)
}
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewTeamService(client)
team, err := svc.Current(context.Background())
if (err != nil) != tt.wantErr {
t.Errorf("TeamService.Current() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && team.Name != tt.wantName {
t.Errorf("TeamService.Current() got name %s, want %s", team.Name, tt.wantName)
}
})
}
}
func TestTeamService_ListMembers(t *testing.T) {
tests := []struct {
name string
teamID string
response string
statusCode int
wantErr bool
wantCount int
}{
{
name: "successful list members",
teamID: "team-123",
response: `[
{"uuid": "user-1", "name": "Alice", "email": "alice@example.com", "role": "admin"},
{"uuid": "user-2", "name": "Bob", "email": "bob@example.com", "role": "member"}
]`,
statusCode: http.StatusOK,
wantErr: false,
wantCount: 2,
},
{
name: "empty members list",
teamID: "team-456",
response: `[]`,
statusCode: http.StatusOK,
wantErr: false,
wantCount: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
expectedPath := "/api/v1/teams/" + tt.teamID + "/members"
if r.URL.Path != expectedPath {
t.Errorf("Expected path %s, got %s", expectedPath, r.URL.Path)
}
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewTeamService(client)
members, err := svc.ListMembers(context.Background(), tt.teamID)
if (err != nil) != tt.wantErr {
t.Errorf("TeamService.ListMembers() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(members) != tt.wantCount {
t.Errorf("TeamService.ListMembers() got %d members, want %d", len(members), tt.wantCount)
}
})
}
}
func TestTeamService_CurrentMembers(t *testing.T) {
tests := []struct {
name string
response string
statusCode int
wantErr bool
wantCount int
}{
{
name: "successful list current members",
response: `[
{"uuid": "user-1", "name": "Alice", "email": "alice@example.com"},
{"uuid": "user-2", "name": "Bob", "email": "bob@example.com"}
]`,
statusCode: http.StatusOK,
wantErr: false,
wantCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/teams/current/members" {
t.Errorf("Expected path /api/v1/teams/current/members, got %s", r.URL.Path)
}
w.WriteHeader(tt.statusCode)
w.Write([]byte(tt.response))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewTeamService(client)
members, err := svc.CurrentMembers(context.Background())
if (err != nil) != tt.wantErr {
t.Errorf("TeamService.CurrentMembers() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(members) != tt.wantCount {
t.Errorf("TeamService.CurrentMembers() got %d members, want %d", len(members), tt.wantCount)
}
// Verify JSON marshaling works
if !tt.wantErr && len(members) > 0 {
_, err := json.Marshal(members[0])
if err != nil {
t.Errorf("Failed to marshal team member: %v", err)
}
}
})
}
}
Regular → Executable
+267 -38
View File
@@ -1,11 +1,103 @@
#!/bin/bash
# This script installs the coolify-cli to /usr/local/bin/coolify from Github release
# This script installs the coolify-cli from GitHub releases
# Supports Linux and macOS on amd64/arm64 architectures
# Windows is not supported by this installer
args=("$@")
custom_version=${args[0]}
if [ -z "$custom_version" ]; then
custom_version="0.0.1"
fi
set -e # Exit on error
# Configuration
REPO="coollabsio/coolify-cli"
BINARY_NAME="coolify"
GLOBAL_INSTALL_DIR="/usr/local/bin"
USER_INSTALL_DIR="$HOME/.local/bin"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Cleanup trap
TEMP_FILE=""
cleanup() {
if [ -n "$TEMP_FILE" ] && [ -f "$TEMP_FILE" ]; then
rm -f "$TEMP_FILE"
fi
}
trap cleanup EXIT
# Error handler
error_exit() {
echo -e "${RED}Error: $1${NC}" >&2
exit 1
}
# Show help
show_help() {
cat << EOF
Coolify CLI Installer
Usage: $0 [OPTIONS] [VERSION]
OPTIONS:
--user Install to ~/.local/bin (no sudo required)
--help Show this help message
--version Show installer version
ARGUMENTS:
VERSION Specific version to install (e.g., v1.0.0)
If not specified, installs the latest release
EXAMPLES:
$0 Install latest version to /usr/local/bin
$0 --user Install latest version to ~/.local/bin
$0 v1.0.0 Install specific version to /usr/local/bin
$0 --user v1.0.0 Install specific version to ~/.local/bin
EOF
exit 0
}
# Parse arguments
USER_INSTALL=false
CUSTOM_VERSION=""
while [[ $# -gt 0 ]]; do
case $1 in
--user)
USER_INSTALL=true
shift
;;
--help|-h)
show_help
;;
--version)
echo "Coolify CLI Installer v1.0.0"
exit 0
;;
*)
CUSTOM_VERSION="$1"
shift
;;
esac
done
# Check required tools
check_requirements() {
local missing_tools=()
if ! command -v curl &> /dev/null; then
missing_tools+=("curl")
fi
if ! command -v tar &> /dev/null; then
missing_tools+=("tar")
fi
if [ ${#missing_tools[@]} -gt 0 ]; then
error_exit "Missing required tools: ${missing_tools[*]}\nPlease install them and try again."
fi
}
# Function to detect platform, architecture, etc.
detect_platform() {
@@ -13,54 +105,191 @@ detect_platform() {
ARCH=$(uname -m)
case $OS in
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*)
echo "Unsupported operating system: $OS"
exit 1
;;
Linux) OS="linux" ;;
Darwin) OS="darwin" ;;
*)
error_exit "Unsupported operating system: $OS\nSupported: Linux, macOS"
;;
esac
case $ARCH in
x86_64) ARCH="amd64" ;;
aarch64 | arm64) ARCH="arm64" ;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
x86_64) ARCH="amd64" ;;
aarch64 | arm64) ARCH="arm64" ;;
*)
error_exit "Unsupported architecture: $ARCH\nSupported: x86_64/amd64, aarch64/arm64"
;;
esac
}
# Function to download file from GitHub release
# Fetch latest release version from GitHub
get_latest_version() {
echo "Fetching latest release version..." >&2
local latest_version
latest_version=$(curl -sSf "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [ -z "$latest_version" ]; then
error_exit "Failed to fetch latest release version from GitHub"
fi
echo "$latest_version"
}
# Validate version format (should start with v or be a semantic version)
validate_version() {
local version=$1
# Check if version starts with 'v' or is a plain semantic version
if [[ ! "$version" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then
error_exit "Invalid version format: $version\nExpected format: v1.0.0 or 1.0.0"
fi
# Ensure version starts with 'v' for GitHub releases
if [[ ! "$version" =~ ^v ]]; then
version="v${version}"
fi
echo "$version"
}
# Check if coolify is already installed
check_existing_installation() {
if command -v coolify &> /dev/null; then
local current_version
current_version=$(coolify version 2>/dev/null | head -n1 || echo "unknown")
echo -e "${YELLOW}Coolify CLI is already installed: ${current_version}${NC}"
echo -e "This will upgrade/reinstall to version ${GREEN}${1}${NC}"
read -p "Continue? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Installation cancelled."
exit 0
fi
fi
}
# Download and verify file from GitHub release
download_from_github() {
local repo=$1
local release=$2
local name=$3
local filename=${name}_${release}_${OS}_${ARCH}.tar.gz
# https://github.com/coollabsio/coolify-cli/releases/download/0.0.1/coolify-cli_0.0.1_linux_amd64.tar.gz
# Construct download URL
local install_dir=$4
local filename="${name}_${release}_${OS}_${ARCH}.tar.gz"
local download_url="https://github.com/${repo}/releases/download/${release}/${filename}"
# Use curl to download the file quietly
echo "Downloading ${name} from ${download_url}"
curl -sL -o "${filename}" "${download_url}"
echo -e "${GREEN}Downloading ${name} ${release}${NC}"
echo "Platform: ${OS}/${ARCH}"
echo "URL: ${download_url}"
# Determine the binary directory
local binary_dir=""
if [ "$OS" == "linux" ] || [ "$OS" == "darwin" ]; then
binary_dir="/usr/local/bin"
# Create temp file
TEMP_FILE=$(mktemp)
# Download with progress and error handling
if ! curl -fSL --progress-bar -o "${TEMP_FILE}" "${download_url}"; then
error_exit "Failed to download from ${download_url}\nPlease check if the version exists or try again later."
fi
echo "Installing ${name} to ${binary_dir}/coolify"
sudo tar -xzvf "${filename}" -C "${binary_dir}" > /dev/null
# Make the binary executable
sudo chmod +x "${binary_dir}/coolify"
# Verify downloaded file is not empty
if [ ! -s "$TEMP_FILE" ]; then
error_exit "Downloaded file is empty"
fi
# Cleanup
rm "${filename}"
# Check if file is actually a tar.gz (basic check)
if ! file "$TEMP_FILE" | grep -q "gzip compressed"; then
error_exit "Downloaded file is not a valid gzip archive"
fi
echo "${name} installed successfully to ${binary_dir}/coolify"
# Create install directory if it doesn't exist (for user install)
if [ "$USER_INSTALL" = true ] && [ ! -d "$install_dir" ]; then
echo "Creating directory: ${install_dir}"
mkdir -p "$install_dir" || error_exit "Failed to create directory ${install_dir}"
fi
# Extract binary
echo "Installing ${name} to ${install_dir}/${BINARY_NAME}"
if [ "$USER_INSTALL" = true ]; then
if ! tar -xzf "${TEMP_FILE}" -C "${install_dir}"; then
error_exit "Failed to extract binary"
fi
chmod +x "${install_dir}/${BINARY_NAME}" || error_exit "Failed to make binary executable"
else
if ! sudo tar -xzf "${TEMP_FILE}" -C "${install_dir}"; then
error_exit "Failed to extract binary (sudo required)"
fi
if ! sudo chmod +x "${install_dir}/${BINARY_NAME}"; then
error_exit "Failed to make binary executable (sudo required)"
fi
fi
# Verify installation
if [ ! -f "${install_dir}/${BINARY_NAME}" ]; then
error_exit "Binary was not installed to ${install_dir}/${BINARY_NAME}"
fi
echo -e "${GREEN}${name} installed successfully to ${install_dir}/${BINARY_NAME}${NC}"
# Check if install directory is in PATH
if [[ ":$PATH:" != *":${install_dir}:"* ]]; then
echo -e "${YELLOW}Warning: ${install_dir} is not in your PATH${NC}"
if [ "$USER_INSTALL" = true ]; then
echo "Add it to your PATH by adding this line to your ~/.bashrc or ~/.zshrc:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
fi
fi
# Show installed version
local installed_version
if installed_version=$("${install_dir}/${BINARY_NAME}" version 2>/dev/null | head -n1); then
echo -e "Installed version: ${GREEN}${installed_version}${NC}"
fi
}
detect_platform
download_from_github "coollabsio/coolify-cli" $custom_version "coolify-cli"
# Main installation flow
main() {
echo "Coolify CLI Installer"
echo "===================="
echo
# Check requirements first
check_requirements
# Detect platform
detect_platform
# Determine version to install
local version_to_install
if [ -z "$CUSTOM_VERSION" ]; then
version_to_install=$(get_latest_version)
else
version_to_install=$(validate_version "$CUSTOM_VERSION")
fi
echo "Version to install: ${version_to_install}"
echo
# Check existing installation
check_existing_installation "$version_to_install"
# Determine install directory
local install_dir
if [ "$USER_INSTALL" = true ]; then
install_dir="$USER_INSTALL_DIR"
echo "Install mode: User (no sudo required)"
else
install_dir="$GLOBAL_INSTALL_DIR"
echo "Install mode: Global (requires sudo)"
fi
echo "Install directory: ${install_dir}"
echo
# Download and install
download_from_github "$REPO" "$version_to_install" "coolify-cli" "$install_dir"
echo
echo -e "${GREEN}Installation complete!${NC}"
echo "Run 'coolify --help' to get started"
}
# Run main installation
main
+24
View File
@@ -0,0 +1,24 @@
{
"uuid": "proj-123-uuid",
"name": "My Project",
"description": "Test project",
"environments": [
{
"id": 1,
"uuid": "env-123-uuid",
"name": "production",
"description": "Production environment",
"created_at": "2025-10-14T12:00:00Z",
"updated_at": "2025-10-14T12:00:00Z",
"applications": [
{
"id": 1,
"uuid": "app-123-uuid",
"name": "web-app",
"description": "Web application",
"status": "running"
}
]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "production-server",
"ip": "192.168.1.100",
"user": "root",
"port": 22,
"settings": {
"is_reachable": true,
"is_usable": true
}
}