Compare commits

..

27 Commits

Author SHA1 Message Date
Laurence 9a128de992 enhance: fixes and update goreleaser to not set rc as latest 2025-04-21 09:59:20 +01:00
Laurence 6495804344 enhance: various enhancements and adding our own updater package 2025-04-20 13:42:59 +01:00
Laurence 3a994cb19e enhance: various enhancements and adding gocritic to improve codebase 2025-04-20 11:38:53 +01:00
Laurence 9b8992d177 enhance: private key fixes 2025-04-19 13:44:13 +01:00
Laurence de6418e532 enhance: Allow private key generation output to be optional and add a force if they want to overwrite on filesystem 2025-04-19 13:39:36 +01:00
Laurence 5e8c823637 enhance: Various enhancements 2025-04-19 13:20:07 +01:00
Laurence 7c370540e2 enhance: Updates and fix vagrant init password generator 2025-04-15 07:52:22 +01:00
Laurence 35f152b3d1 enhance: Switch over to client SDK codegen, note it current panics in servers list 2025-04-11 22:27:30 +01:00
Laurence 2b8a3bd120 enhance: Remove altscreen for now 2025-04-06 00:45:52 +01:00
Laurence 77a61d614e enhance: Fix filterabletable deletion and use filtertable in cliinstances 2025-04-06 00:29:10 +01:00
Laurence 255b918d02 enhance: Create filtertable reuseable component will expand to other commands 2025-04-05 22:58:50 +01:00
Laurence 200313c1b8 enhance: Expand private keys functions, Create pkg/tui which is a helper to generate branded terminal UI items 2025-04-05 19:58:42 +01:00
Laurence dd0d46b0fc enhance: Add vagrant file to automated setting up a local coolify for cli testing 2025-04-05 15:02:32 +01:00
Laurence 7c6a6b4292 wip: Start implemented privatekeys functionality (not tested) 2025-04-02 18:32:46 +01:00
Laurence ef4a847f10 wip: fix goreleaser title the os 2025-04-01 13:02:11 +01:00
Laurence b22f7b6943 wip: Rename repo from coolify-cli to cli-coolify 2025-04-01 12:56:42 +01:00
Laurence 9a4ef0d6ac wip: Fixes and general updates 2025-04-01 12:47:29 +01:00
Laurence 98a624af27 wip: more changes 2025-04-01 12:20:18 +01:00
Laurence cb185da557 wip: Model changed, Using text inputs provided by bubbles instead of computing it overselves 2025-04-01 09:33:20 +01:00
Laurence d809990bec wip: init is now pretty 2025-03-31 19:29:23 +01:00
Laurence f66c4f4217 wip: init now uses bubbletea 2025-03-31 19:18:58 +01:00
Laurence decc3e092a wip: readd the update command 2025-03-31 18:24:56 +01:00
Laurence 611b14d2ea wip: update list to use new table 2025-03-31 18:15:57 +01:00
Laurence d22e6607a9 wip: update cursorrules and vibe code 2025-03-31 17:37:52 +01:00
Laurence 1126defb7c wip: update cursorrules and vibe code 2025-03-31 17:37:30 +01:00
Laurence b4148d6344 wip 2025-03-23 18:30:41 +00:00
Laurence 8c38a5447a wip: started refactoring, need to work on implementing the rest of v0.0.1 commands but built a baseline 2025-03-22 17:50:46 +00:00
231 changed files with 20713 additions and 20428 deletions
-46
View File
@@ -1,46 +0,0 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./coolify"
cmd = "go build -o ./coolify ./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/coolify'"
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = true
keep_scroll = true
+38 -25
View File
@@ -1,29 +1,42 @@
You are an expert AI programming assistant specializing in building CLI applications with Go, using Cobra for command-line interface management and Bubble Tea for terminal user interfaces.
You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22.
Always use Go 1.24 and be familiar with CLI development best practices, Go idioms, and terminal UI design principles.
Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms.
When using lipgloss for terminal styling, use these Coolify brand colors via the pkg/tui package.
- Follow the user's requirements carefully & to the letter.
- First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail.
- Confirm the plan, then write code!
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs.
- Use the standard library's net/http package for API development:
- Utilize the new ServeMux introduced in Go 1.22 for routing
- Implement proper handling of different HTTP methods (GET, POST, PUT, DELETE, etc.)
- Use method handlers with appropriate signatures (e.g., func(w http.ResponseWriter, r *http.Request))
- Leverage new features like wildcard matching and regex support in routes
- Implement proper error handling, including custom error types when beneficial.
- Use appropriate status codes and format JSON responses correctly.
- Implement input validation for API endpoints.
- Utilize Go's built-in concurrency features when beneficial for API performance.
- Follow RESTful API design principles and best practices.
- Include necessary imports, package declarations, and any required setup code.
- Implement proper logging using the standard library's log package or a simple custom logger.
- Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication).
- Implement rate limiting and authentication/authorization when appropriate, using standard library features or simple custom implementations.
- Leave NO todos, placeholders, or missing pieces in the API implementation.
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
- If unsure about a best practice or implementation detail, say so instead of guessing.
- Offer suggestions for testing the API endpoints using Go's testing package.
When searching for schemas look at https://github.com/coollabsio/coolify/blob/main/openapi.yaml to find the most up to date schema for the struct we are looking to define. Make sure when creating a schema that you place the struct in cmd/coolTypes package.
Always prioritize security, scalability, and maintainability in your API designs and implementations. Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs.
- First think step-by-step - describe your plan for the CLI structure, commands, and user interaction flow in pseudocode, written out in great detail.
- Confirm the plan, then write code!
- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for CLI applications.
- Use Cobra for command-line interface development:
- Organize commands in a clear, hierarchical structure
- Implement proper command flags and arguments
- Use persistent flags when appropriate
- Follow Cobra's best practices for command organization
- Implement proper command aliases and short descriptions
- Use Bubble Tea for terminal user interfaces:
- Design intuitive and responsive terminal UIs
- Implement proper state management
- Handle user input appropriately
- Use appropriate Bubble Tea components and styling
- Follow terminal UI best practices
- Implement proper error handling, including custom error types when beneficial
- Use appropriate exit codes and error messages
- Implement input validation for command arguments and flags
- Utilize Go's built-in concurrency features when beneficial for CLI performance
- Follow CLI design principles and best practices:
- Keep commands simple and focused
- Use clear, consistent naming conventions
- Provide helpful usage information
- Implement proper help text and documentation
- Include necessary imports, package declarations, and any required setup code
- Implement proper logging using appropriate CLI-friendly logging packages
- Consider implementing middleware for cross-cutting concerns (e.g., logging, configuration)
- Implement proper configuration management when appropriate
- Leave NO todos, placeholders, or missing pieces in the CLI implementation
- Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms
- If unsure about a best practice or implementation detail, say so instead of guessing
- Offer suggestions for testing the CLI commands using Go's testing package
Always prioritize user experience, maintainability, and cross-platform compatibility in your CLI designs and implementations. Leverage the power of Cobra and Bubble Tea to create efficient and user-friendly terminal applications.
-7
View File
@@ -1,7 +0,0 @@
## Changes
-
## Issues & Discussions
- fix #
+5 -34
View File
@@ -10,47 +10,18 @@ jobs:
release-cli:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: ${{ env.GITHUB_REF_NAME }}
args: release --clean
workdir: ./
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-version:
needs: [release-cli]
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: v4.x
fetch-depth: 0
- name: Update version file
run: |
TAG=${GITHUB_REF#refs/tags/}
echo "Updating version to $TAG"
sed -i "s/^\tversion = \".*\"/\tversion = \"$TAG\"/" internal/version/checker.go
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add internal/version/checker.go
git commit -m "chore: bump version to $TAG"
git push origin v4.x
# Move the tag to point to the new commit with updated version
git tag -d "$TAG" || true
git tag "$TAG"
git push origin "refs/tags/$TAG" --force
-55
View File
@@ -1,55 +0,0 @@
name: Testing CLI
on:
push:
branches: ["v4.x"]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Run gofmt
run: diff -u <(echo -n) <(gofmt -d -s .)
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.5.0 # pin version for consistency
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Run tests
run: go test -v -race -cover ./...
go-mod-tidy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Run go mod tidy
run: go mod tidy
- name: Check uncommitted changes
run: git diff --exit-code
- if: failure()
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
+6 -9
View File
@@ -1,12 +1,9 @@
coolify-cli
/coolify
cli-coolify
coolify
cli
config.json
.claude
dist
.vagrant
.test
# Generated documentation (can be regenerated)
man/
docs/cli/
dist/
# Test coverage
coverage.out
+13 -66
View File
@@ -1,75 +1,22 @@
version: "2"
run:
timeout: 5m
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- durationcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocritic
- gomoddirectives
- gomodguard
- gosec
- gosmopolitan
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- revive
- rowserrcheck
- spancheck
- sqlclosecheck
- testifylint
- unparam
- zerologlint
settings:
exhaustive:
default-signifies-exhaustive: true
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- hugeParam
- rangeValCopy
staticcheck:
checks: ["all", "-ST1005", "-S1016"]
gosec:
excludes:
- G115
gosmopolitan:
allow-time-local: true
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- std-error-handling
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gci
- goimports
settings:
gci:
sections:
- standard
- default
- prefix(github.com/coollabsio)
exclusions:
generated: lax
paths:
- "pkg/gen/*.go"
+12 -25
View File
@@ -1,19 +1,8 @@
version: 2
before:
hooks:
- go mod tidy
builds:
- id: coolify
binary: coolify
flags:
- -trimpath
ldflags:
- -s
- -w
- -X github.com/coollabsio/coolify-cli/internal/version.version={{ .Version }}
main: ./coolify/main.go
- binary: coolify
goos:
- darwin
- linux
@@ -21,19 +10,17 @@ builds:
goarch:
- amd64
- arm64
ldflags:
- -s -w -X github.com/coollabsio/cli-coolify/cmd/runtime.Version={{.Version}}
env:
- CGO_ENABLED=0
checksum:
name_template: checksums.txt
algorithm: sha256
archives:
- id: coolify-archive
ids:
- coolify
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
format_overrides:
- goos: windows
formats: [zip]
- formats: ['tar.gz']
name_template: >-
coolify_{{ .Version }}_
{{- .Os }}_{{ .Arch }}
checksum:
name_template: 'coolify_{{ .Version }}_checksums.txt'
release:
prerelease: auto
make_latest: "{{ not .Prerelease }}"
-587
View File
@@ -1,587 +0,0 @@
# Coolify CLI Architecture
This document describes the architecture and design principles of the Coolify CLI.
## Overview
The Coolify CLI is a command-line interface for managing Coolify instances, servers, projects, and deployments. It follows a layered architecture pattern that separates concerns and promotes maintainability.
## Architecture Layers
```
┌─────────────────────────────────────────────────────────┐
│ User Interface │
│ (Terminal/Shell) │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Command Layer (cmd/) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ servers │ │ deploy │ │ projects │ ... │
│ └──────────┘ └──────────┘ └──────────┘ │
│ • CLI parsing & validation │
│ • Flag handling │
│ • Output formatting │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Service Layer (internal/service/) │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ServerService│ │DeployService │ │ProjectService│ │
│ └─────────────┘ └──────────────┘ └──────────────┘ │
│ • Business logic │
│ • Request validation │
│ • Response transformation │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ API Client Layer (internal/api/) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ HTTP Client (api.Client) │ │
│ └───────────────────────────────────────────────────┘ │
│ • HTTP requests/responses │
│ • Authentication (Bearer tokens) │
│ • Retry logic │
│ • Error handling │
└────────────────────────┬────────────────────────────────┘
┌────────────────────────▼────────────────────────────────┐
│ Coolify API (External) │
│ https://instance.coolify.io/api/v1/ │
└─────────────────────────────────────────────────────────┘
```
## Supporting Components
```
┌─────────────────────────────────────────────────────────┐
│ Configuration (internal/config/) │
│ • Multi-instance management │
│ • Default instance selection │
│ • Token storage │
│ • ~/.config/coolify/config.json │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Output Formatters (internal/output/) │
│ ┌─────────┐ ┌────────┐ ┌─────────┐ │
│ │ Table │ │ JSON │ │ Pretty │ │
│ └─────────┘ └────────┘ └─────────┘ │
│ • Flexible output formats │
│ • Sensitive data masking │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Data Models (internal/models/) │
│ • Server, Project, Resource, Deployment │
│ • Request/Response structures │
│ • JSON marshaling/unmarshaling │
└─────────────────────────────────────────────────────────┘
```
## Layer Responsibilities
### 1. Command Layer (`cmd/`)
**Purpose**: Handle CLI user interface and interaction
**Responsibilities**:
- Parse command-line arguments and flags
- Validate user input
- Coordinate with service layer
- Format and display output
- Handle errors gracefully
**Key Files**:
- `root.go` - Root command, global flags, initialization
- `servers.go` - Server management commands
- `deploy.go` - Deployment commands
- `context.go` - Context (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 ./coolify
# Install locally
go install ./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)
-344
View File
@@ -1,344 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a CLI tool for interacting with the Coolify API, built with Go using the Cobra framework. The CLI allows users to manage Coolify instances (both cloud and self-hosted), servers, projects, resources, deployments, domains, and private keys.
### API Specification
This CLI is a client for the Coolify API. The API specification is defined in the OpenAPI schema:
- **Source**: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
- **Raw JSON**: https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/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: `coolify/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/`:
- `context.go` - manage Coolify context (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 ./coolify
```
### Run locally
```bash
go run ./coolify [command]
```
### Test a command
```bash
go run ./coolify context list
go run ./coolify servers list --debug
```
### Install locally
```bash
go install ./coolify
```
### 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
-620
View File
@@ -1,620 +0,0 @@
# Contributing to Coolify CLI
Thank you for your interest in contributing to the Coolify CLI! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Project Architecture](#project-architecture)
- [Adding a New Command](#adding-a-new-command)
- [Testing Requirements](#testing-requirements)
- [Code Style & Conventions](#code-style--conventions)
- [Submitting Changes](#submitting-changes)
## Getting Started
Before you start contributing:
1. **Read the [ARCHITECTURE.md](ARCHITECTURE.md)** for detailed architectural guidance
2. **Review the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)** to understand available API endpoints
3. **Check existing issues** to see if your feature/bug is already being worked on
4. **Open an issue** to discuss your proposed changes (for large features)
### Prerequisites
- Go 1.24 or higher
- Git
## Development Setup
### Clone and Build
```bash
# Fork the repository on GitHub
# Clone your fork
git clone https://github.com/YOUR_USERNAME/coolify-cli.git
cd coolify-cli
# Build the CLI
go build -o coolify ./coolify
# Install locally
go install
```
### Running the CLI
```bash
# Run without installing
go run ./coolify [command]
# Example commands
go run ./coolify context list
go run ./coolify server list --debug
# With flags
go run ./coolify server list --format json --debug
```
### Project Structure
```
cmd/ # CLI commands (organized by feature)
├── root.go # Root command and global flags
├── application/ # Application management commands
├── context/ # Manage Coolify instances
├── server/ # Server management
├── project/ # Project management
├── database/ # Database management
├── deployment/ # Deployment operations
├── service/ # Service management
└── ...
internal/ # Internal packages
├── api/ # API client (HTTP communication)
├── cli/ # CLI utilities (GetAPIClient helper)
├── config/ # Configuration management
├── models/ # Data models and structs
├── output/ # Output formatters (table, json, pretty)
├── parser/ # Input parsing utilities
├── service/ # Business logic layer
└── version/ # Version management
test/ # Test utilities and fixtures
└── fixtures/ # Mock API response data
```
## Project Architecture
The Coolify CLI follows a **layered architecture**:
```
User → Commands (cmd/) → Services (internal/service/) → API Client (internal/api/) → Coolify API
```
### Layer Responsibilities
1. **Command Layer** (`cmd/`)
- Parse CLI arguments and flags
- Call service layer methods
- Format output using output formatters
2. **Service Layer** (`internal/service/`)
- Business logic
- Coordinate API calls
- Transform data
3. **API Client Layer** (`internal/api/`)
- HTTP communication
- Retry logic with exponential backoff
- Authentication (Bearer tokens)
- Error handling
### Key Dependencies
- **cobra**: CLI framework
- **viper**: Configuration management
- **stretchr/testify**: Testing assertions
## Adding a New Command
Follow these steps to add a new command:
### 1. Create Command Directory Structure
```bash
# Create directory for your command
mkdir -p cmd/myfeature
```
### 2. Create Parent Command
Create `cmd/myfeature/myfeature.go`:
```go
package myfeature
import "github.com/spf13/cobra"
// NewMyFeatureCommand creates the myfeature parent command
func NewMyFeatureCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "myfeature",
Aliases: []string{"mf"},
Short: "MyFeature related commands",
Long: `Manage MyFeature resources.`,
}
// Add subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
// ... more subcommands
return cmd
}
```
### 3. Create Subcommand
Create `cmd/myfeature/list.go`:
```go
package myfeature
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
"github.com/spf13/cobra"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all myfeature resources",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Get API client
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Use service layer
svc := service.NewMyFeatureService(client)
items, err := svc.List(ctx)
if err != nil {
return fmt.Errorf("failed to list items: %w", err)
}
// Format output
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(items)
},
}
}
```
### 4. Create Service Layer
Create `internal/service/myfeature.go`:
```go
package service
import (
"context"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
type MyFeatureService struct {
client *api.Client
}
func NewMyFeatureService(client *api.Client) *MyFeatureService {
return &MyFeatureService{client: client}
}
func (s *MyFeatureService) List(ctx context.Context) ([]models.MyFeature, error) {
var items []models.MyFeature
err := s.client.Get(ctx, "myfeature", &items)
return items, err
}
func (s *MyFeatureService) Get(ctx context.Context, uuid string) (*models.MyFeature, error) {
var item models.MyFeature
err := s.client.Get(ctx, "myfeature/"+uuid, &item)
return &item, err
}
func (s *MyFeatureService) Create(ctx context.Context, req models.MyFeatureCreateRequest) (*models.Response, error) {
var response models.Response
err := s.client.Post(ctx, "myfeature", req, &response)
return &response, err
}
func (s *MyFeatureService) Delete(ctx context.Context, uuid string) error {
return s.client.Delete(ctx, "myfeature/"+uuid)
}
```
### 5. Create Models
Create `internal/models/myfeature.go`:
```go
package models
type MyFeature struct {
ID int `json:"id" table:"-"` // Hidden from table output
UUID string `json:"uuid"` // Shown to users
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
// Add more fields...
}
type MyFeatureCreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
}
```
**Important**: Always use `UUID` for user-facing identifiers, not database `ID`. Hide `ID` field from table output using `table:"-"` tag.
### 6. Register Command
Add your command to `cmd/root.go`:
```go
import (
// ... existing imports
"github.com/coollabsio/coolify-cli/cmd/myfeature"
)
func init() {
// ... existing code
rootCmd.AddCommand(myfeature.NewMyFeatureCommand())
}
```
### 7. Create Tests
Create `internal/service/myfeature_test.go`:
```go
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 TestMyFeatureService_List(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/myfeature", r.URL.Path)
assert.Equal(t, "GET", r.Method)
items := []models.MyFeature{
{UUID: "uuid-1", Name: "item-1"},
{UUID: "uuid-2", Name: "item-2"},
}
json.NewEncoder(w).Encode(items)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewMyFeatureService(client)
items, err := svc.List(cmd.Context())
require.NoError(t, err)
assert.Len(t, items, 2)
assert.Equal(t, "uuid-1", items[0].UUID)
}
```
### 8. Update Documentation
- Add command documentation to `README.md`
- Include usage examples and flag descriptions
## Testing Requirements
**All code changes MUST include tests.** This is non-negotiable.
### Coverage Requirements
- **Minimum coverage**: 70% for all packages
- **New features**: 80%+ coverage required
- **Bug fixes**: Must include regression tests
- **Refactoring**: Must maintain or improve existing coverage
### Running Tests
```bash
# Run all tests
go test ./internal/...
# Run with coverage
go test ./internal/... -cover
# Run specific package
go test ./internal/service/... -v
# Run specific test
go test ./internal/service -run TestServerService_List -v
# Generate coverage report
go test ./internal/... -coverprofile=coverage.out
go tool cover -html=coverage.out
```
### Writing Tests
#### Use Table-Driven Tests
```go
func TestMyFunction(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "successful case",
input: "test",
want: "expected",
wantErr: false,
},
{
name: "error case",
input: "",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MyFunction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("MyFunction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("MyFunction() = %v, want %v", got, tt.want)
}
})
}
}
```
#### Mock HTTP Requests
**IMPORTANT**: Never call real APIs in tests. Use `httptest.NewServer()`:
```go
func TestServiceMethod(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify request
assert.Equal(t, "/api/v1/endpoint", r.URL.Path)
assert.Equal(t, "GET", r.Method)
// Return mock response
response := models.MyResponse{Data: "test"}
json.NewEncoder(w).Encode(response)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
// ... test your service
}
```
### Test Guidelines
- **Test naming**: `TestFunctionName_Scenario_ExpectedBehavior`
- **Use subtests**: `t.Run()` for related test cases
- **Use testify**: `require.NoError()` for must-pass assertions, `assert.Equal()` for comparisons
- **Mock HTTP**: Use `httptest.NewServer()` for all API tests
- **Test contexts**: Always pass `context.Background()` in tests
- **Test errors**: Verify error messages and types
## Code Style & Conventions
### Go Standards
- Follow standard Go idioms and conventions
- Use `gofmt` for code formatting
- Run `go vet` to catch common issues
- Prefer standard library over external dependencies
### Project Conventions
#### API Client Usage
```go
// Create client (usually done via cli.GetAPIClient())
client := api.NewClient(baseURL, token, api.WithDebug(true))
// GET request
var result MyStruct
err := client.Get(ctx, "endpoint", &result)
// POST request
err := client.Post(ctx, "endpoint", requestBody, &result)
// DELETE request
err := client.Delete(ctx, "endpoint")
// PATCH request
err := client.Patch(ctx, "endpoint", requestBody, &result)
```
#### Service Layer Pattern
```go
type MyService struct {
client *api.Client
}
func NewMyService(client *api.Client) *MyService {
return &MyService{client: client}
}
func (s *MyService) List(ctx context.Context) ([]models.Item, error) {
var items []models.Item
err := s.client.Get(ctx, "items", &items)
return items, err
}
```
#### Error Handling
```go
// Wrap errors with context
if err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
// Check and handle specific error types
if apiErr, ok := err.(*api.Error); ok {
if apiErr.StatusCode == 404 {
return fmt.Errorf("resource not found")
}
}
```
#### Global Flags
All commands automatically inherit these global flags:
- `--format` (table|json|pretty) - Output format
- `--show-sensitive` - Show sensitive information
- `--debug` - Enable debug mode
- `--context` - Use specific context by name
- `--token` - Override context token
Access flags in commands:
```go
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
debug, _ := cmd.Flags().GetBool("debug")
```
## Submitting Changes
### Before Committing
```bash
# 1. Format code
go fmt ./...
# 2. Run tests
go test ./internal/...
# 3. Check coverage
go test ./internal/... -cover
# 4. Run vet
go vet ./...
```
### Commit Messages
Write clear, descriptive commit messages following conventional commits format:
```
<type>: <short summary>
<detailed description>
<footer>
```
Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`
Example:
```
feat: add server domains list command
- Implement GET /servers/{uuid}/domains endpoint
- Add server domains subcommand
- Include tests for domain listing
- Update README with new command documentation
```
### Pull Requests
1. **Fork** the repository
2. **Create a branch** from `v4.x`: `git checkout -b feature/my-feature v4.x`
3. **Make your changes** with tests
4. **Push** to your fork: `git push origin feature/my-feature`
5. **Open a pull request** against the `v4.x` branch
6. **Describe your changes** clearly in the PR description
7. **Link related issues** using "Fixes #123" or "Closes #123"
### PR Checklist
- [ ] Tests pass locally (`go test ./internal/...`)
- [ ] Code coverage meets requirements (70%+ minimum)
- [ ] Code is formatted (`go fmt ./...`)
- [ ] README.md updated (if adding new commands)
- [ ] CLAUDE.md updated (if changing architecture)
- [ ] Commit messages are descriptive
- [ ] PR description explains the changes
- [ ] All global flags are supported (format, show-sensitive, debug)
- [ ] Used UUIDs (not IDs) for resource identifiers
## Release Process (not for contributors :) )
Releases are automated using GoReleaser:
1. Tag a new version: `git tag v1.2.3`
2. Push the tag: `git push origin v1.2.3`
3. Create a GitHub release
4. GoReleaser builds binaries for all platforms automatically
## Getting Help
- **Discord**: https://coolify.io/discord
- **Issues**: [Open an issue](https://github.com/coollabsio/coolify-cli/issues) for bugs or feature requests
- **Architecture**: Read [ARCHITECTURE.md](ARCHITECTURE.md) for detailed design documentation
- **API Reference**: See the [OpenAPI specification](https://github.com/coollabsio/coolify/blob/v4.x/openapi.json)
- **Code Guidance**: See [CLAUDE.md](CLAUDE.md) for AI assistant guidance
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
---
Thank you for contributing to Coolify CLI! 🚀
-123
View File
@@ -1,123 +0,0 @@
# How to Release Coolify CLI
This guide explains the release process for the Coolify CLI.
## Prerequisites
- Write access to the `coollabsio/coolify-cli` repository
- All changes merged to the target branch (`v4.x`)
- All tests passing (`go test ./internal/...`)
## Release Process
### 1. 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"
### 2. 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. Goreleaser injects the version from the tag into the binaries
4. Binaries are automatically uploaded to the release
5. 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`
- `go install`: `go install github.com/coollabsio/coolify-cli/coolify@v1.x.x`
### 3. 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.) - points to `/coolify` as entry point
- `.github/workflows/release-cli.yml` - GitHub Actions workflow
- `scripts/install.sh` - User-facing install script
- `internal/version/checker.go` - Contains `GetVersion()` function that returns the current version
- `coolify/main.go` - Binary entry point for `go install` support
## 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
+70 -503
View File
@@ -1,533 +1,100 @@
# CLI for [Coolify](https://coolify.io) API
> [!WARNING]
> Until version 1.0.0, the CLI should be considered unstable. Any minor or patch release may introduce breaking changes. Please read the release notes carefully before updating.
## Installation
### Install script (recommended)
```bash
curl -fsSL https://raw.githubusercontent.com/coollabsio/cli-coolify/main/scripts/install.sh | bash
```
#### Linux/macOS
This will install the CLI in `/usr/local/bin/coolify`.
> If you are a Windows or macOS user, please test the installation script and let us know if it works for you.
## Initial Setup
Before using any commands, you need to initialize the CLI by creating a configuration file:
```bash
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
coolify init
```
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
This interactive wizard will guide you through setting up your Coolify instance(s). You can choose to:
- Connect to Coolify Cloud using your API token
- Add self-hosted Coolify instance(s) with their FQDN and token
#### Windows (PowerShell)
Alternatively, you can generate a default configuration non-interactively:
```powershell
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
```
It will install the CLI in `%ProgramFiles%\Coolify\coolify.exe` and the configuration file in `%USERPROFILE%\.config\coolify\config.json`
For user installation (no admin rights required):
```powershell
$env:COOLIFY_USER_INSTALL=1; irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
```
For a specific version:
```powershell
$env:COOLIFY_VERSION='v1.0.0'; irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
```
### Using `go install`
```bash
go install github.com/coollabsio/coolify-cli/coolify@latest
coolify init --default
```
This will install the `coolify` binary in your `$GOPATH/bin` directory (usually `~/go/bin`). Make sure this directory is in your `$PATH`.
The configuration will be stored in `~/.config/coolify/config.json`.
### Using the install script
## Getting Your API Token
To use the CLI, you'll need an API token:
1. Log in to your Coolify dashboard (Cloud or self-hosted)
2. Navigate to `/security/api-tokens`
3. Create a new token with appropriate permissions
4. Use this token when initializing the CLI or adding a new instance
## Getting Started
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
## Managing Instances
### Cloud
After initialization, you can manage your Coolify instances:
2. Add the token with `coolify context set-token cloud <token>`
### Add a New Instance
### Self-hosted
```bash
coolify instances add MyInstance https://my.instance.tld mytoken
```
2. Add the token with `coolify context add -d <context_name> <url> <token>`
Or use the interactive mode:
> Replace `<context_name>` with the name you want to give to the context.
>
> Replace `<url>` with the fully qualified domain name of your Coolify instance.
```bash
coolify instances add
```
Now you can use the CLI with the token you just added.
### List All Instances
```bash
coolify instances list
```
### Set Default Instance
```bash
coolify instances set default MyInstance
```
### Remove an Instance
```bash
coolify instances remove MyInstance
```
### Update Instance Token
```bash
coolify instances set token MyInstance newtoken
```
## Change default context
You can change the default context with `coolify context use <context_name>` or `coolify context set-default <context_name>`
## Currently Supported Commands
### Update
- `coolify update` - Update the CLI to the latest version
### Configuration
- `coolify config` - Show configuration file location
### Shell Completion
- `coolify completion <shell>` - Generate shell completion script
- Supported shells: `bash`, `zsh`, `fish`, `powershell`
### Context Management
- `coolify context list` - List all configured contexts
- `coolify context add <context_name> <url> <token>` - Add a new context
- `-d, --default` - Set as default context
- `-f, --force` - Force overwrite if context already exists
- `coolify context delete <context_name>` - Delete a context
- `coolify context get <context_name>` - Get details of a specific context
- `coolify context set-token <context_name> <token>` - Update the API token for a context
- `coolify context set-default <context_name>` - Set a context as the default
- `coolify context update <context_name>` - Update a context's properties
- `--name <new_name>` - Change the context name
- `--url <new_url>` - Change the context URL
- `--token <new_token>` - Change the context token
- `coolify context use <context_name>` - Switch to a different context (set as default)
- `coolify context verify` - Verify current context connection and authentication
- `coolify context version` - Get the Coolify API version of the current context
### Instances
- `coolify instances list` - List all instances
- `coolify instances add` - Create a new instance configuration
- `coolify instances remove` - Remove an instance configuration
- `coolify instances get` - Get an instance configuration
- `coolify instances set <default>|<token>` - Set an instance as default or set a token for an instance
- `coolify instances version` - Get the version of the Coolify API for an instance
### Servers
Commands can use `server` or `servers` interchangeably.
- `coolify server list` - List all servers
- `coolify server get <uuid>` - Get a server by UUID
- `--resources` - Get the resources and their status of a server
- `coolify server add <name> <ip> <private_key_uuid>` - Add a new server
- `-p, --port <port>` - SSH port (default: 22)
- `-u, --user <user>` - SSH user (default: root)
- `--validate` - Validate server immediately after adding
- `coolify server remove <uuid>` - Remove a server
- `coolify server validate <uuid>` - Validate a server connection
- `coolify server domains <uuid>` - Get server domains by UUID
### 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
- `--git-branch <branch>` - Git branch
- `--git-repository <url>` - Git repository URL
- `--domains <domains>` - Domains (comma-separated)
- `--build-command <cmd>` - Build command
- `--start-command <cmd>` - Start command
- `--install-command <cmd>` - Install command
- `--base-directory <path>` - Base directory
- `--publish-directory <path>` - Publish directory
- `--dockerfile <content>` - Dockerfile content
- `--docker-image <image>` - Docker image name
- `--docker-tag <tag>` - Docker image tag
- `--ports-exposes <ports>` - Exposed ports
- `--ports-mappings <mappings>` - Port mappings
- `--health-check-enabled` - Enable health check
- `--health-check-path <path>` - Health check path
- `coolify app delete <uuid>` - Delete an application
- `-f, --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)
- `--preview` - Available in preview deployments
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
- `coolify app env update <app_uuid>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `--value <value>` - Variable value (required)
- `--preview` - Available in preview deployments
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
- `--runtime` - Available at runtime
- `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)
- `--build-time` - Make all variables available at build time
- `--preview` - Make all variables available in preview deployments
- `--is-literal` - Treat all values as literal (don't interpolate variables)
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
#### Application Deployments
- `coolify app deployments list <app-uuid>` - List all deployments for an application
- `coolify app deployments logs <app-uuid> [deployment-uuid]` - Get deployment logs (formatted as human-readable text)
- If only `app-uuid` is provided: retrieves logs from the **latest/most recent deployment only**
- If `deployment-uuid` is also provided: retrieves logs for that **specific deployment**
- `-n, --lines <n>` - Number of log lines to display (default: 0 = all lines)
- `-f, --follow` - Follow log output in real-time (like tail -f)
- `--debuglogs` - Show debug logs (includes hidden commands and internal operations)
### 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)
- `--environment-name <name>` - Environment name (required unless using --environment-uuid)
- `--environment-uuid <uuid>` - Environment UUID (required unless using --environment-name)
- `--destination-uuid <uuid>` - Destination UUID if server has multiple destinations
- `--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
- `--limits-memory <size>` - Memory limit (e.g., '512m', '2g')
- `--limits-cpus <cpus>` - CPU limit (e.g., '0.5', '2')
- 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)
- `--delete-connected-networks` - Delete connected networks (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
- `--databases-to-backup <list>` - Comma-separated list of databases to backup
- `--dump-all` - Dump all databases
- `--retention-amount-local <n>` - Number of backups to retain locally
- `--retention-days-local <n>` - Days to retain backups locally
- `--retention-storage-local <size>` - Max storage for local backups (e.g., '1GB', '500MB')
- `--retention-amount-s3 <n>` - Number of backups to retain in S3
- `--retention-days-s3 <n>` - Days to retain backups in S3
- `--retention-storage-s3 <size>` - Max storage for S3 backups (e.g., '1GB', '500MB')
- `--timeout <seconds>` - Backup timeout in seconds
- `--disable-local` - Disable local backup storage
- `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>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `--value <value>` - Variable value (required)
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
- `--runtime` - Available at runtime
- `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)
- `--build-time` - Make all variables available at build time
- `--preview` - Make all variables available in preview deployments
- `--is-literal` - Treat all values as literal (don't interpolate variables)
- **Behavior**: Updates existing variables, creates missing ones. Does NOT delete variables not in the file.
### Deployments
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
- `-f, --force` - Force deployment
- `coolify deploy name <name>` - Deploy a resource by name
- `-f, --force` - Force deployment
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
- `-f, --force` - Force all deployments
- `coolify deploy list` - List all deployments
- `coolify deploy get <uuid>` - Get deployment details
- `coolify deploy cancel <uuid>` - Cancel a deployment
- `-f, --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, e.g., https://api.github.com)
- `--html-url <url>` - GitHub HTML URL (required, e.g., https://github.com)
- `--app-id <id>` - GitHub App ID (required)
- `--installation-id <id>` - GitHub Installation ID (required)
- `--client-id <id>` - GitHub OAuth Client ID (required)
- `--client-secret <secret>` - GitHub OAuth Client Secret (required)
- `--private-key-uuid <uuid>` - UUID of existing private key (required)
- `--organization <org>` - GitHub organization
- `--custom-user <user>` - Custom user for SSH (default: git)
- `--custom-port <port>` - Custom port for SSH (default: 22)
- `--webhook-secret <secret>` - GitHub Webhook Secret
- `--system-wide` - Is this app system-wide (cloud only)
- `coolify github update <app_uuid>` - Update a GitHub App
- `coolify github delete <app_uuid>` - Delete a GitHub App
- `-f, --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 <team_id>` - Get team details
- `coolify team current` - Get current team
- `coolify team members list [team_id]` - List team members
### Private Keys
Commands can use `private-key`, `private-keys`, `key`, or `keys` interchangeably.
- `coolify private-key list` - List all private keys
- `coolify private-key add <key_name> <private-key>` - Add a new private key
- Use `@filename` to read from file: `coolify private-key add mykey @~/.ssh/id_rsa`
- `coolify private-key remove <uuid>` - Remove a private key
## Global Flags
All commands support these global flags:
- `--context <name>` - Use a specific context instead of default
- `--host <fqdn>` - Override the Coolify instance hostname
- `--token <token>` - Override the authentication token
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
- `-s, --show-sensitive` - Show sensitive information (tokens, IPs, etc.)
- `-f, --force` - Force operation (skip confirmations)
- `--debug` - Enable debug mode
## Examples
### Multi-Environment Workflows
```bash
# Add multiple contexts
coolify context add prod https://prod.coolify.io <prod-token>
coolify context add staging https://staging.coolify.io <staging-token>
coolify context add dev https://dev.coolify.io <dev-token>
# Set default
coolify context use prod
# Use different contexts
coolify --context=staging servers list
coolify --context=prod deploy name api
coolify --context=dev resources list
# Default context (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
# Sync from .env file (updates existing, creates new, keeps others unchanged)
coolify app env sync <uuid> --file .env
coolify app env sync <uuid> --file .env.production --build-time --preview
```
### 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 context
coolify --context=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 --context=prod server list
# Add a server with validation
coolify server add myserver 192.168.1.100 <key-uuid> --validate
# Get server details with resources
coolify server get <uuid> --resources
```
## Output Formats
The CLI supports three output formats:
```bash
# Table format (default, human-readable)
coolify server list
# JSON format (for scripts)
coolify server list --format=json
# Pretty JSON (for debugging)
coolify server 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-context configuration management
- **Models Layer**: Type-safe data structures
## Development
```bash
# Build
go build -o coolify ./coolify
# Run tests
go test ./...
# Run with coverage
go test -cover ./...
# Install locally
go install ./coolify
```
## Contributing
Contributions are welcome!
## License
MIT
- `coolify servers list` - List all servers
- `coolify servers get` - Get a server
- `--resources` - Get the resources and their status of a server
-47
View File
@@ -1,47 +0,0 @@
package application
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/application/create"
"github.com/coollabsio/coolify-cli/cmd/application/env"
)
// NewAppCommand creates the app parent command
func NewAppCommand() *cobra.Command {
cmd := &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.`,
}
// Add main subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(create.NewCreateCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewStartCommand())
cmd.AddCommand(NewStopCommand())
cmd.AddCommand(NewRestartCommand())
cmd.AddCommand(NewLogsCommand())
cmd.AddCommand(NewDeploymentsCommand())
// Add env subcommand with its children
envCmd := &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.`,
}
envCmd.AddCommand(env.NewListEnvCommand())
envCmd.AddCommand(env.NewGetEnvCommand())
envCmd.AddCommand(env.NewCreateEnvCommand())
envCmd.AddCommand(env.NewUpdateEnvCommand())
envCmd.AddCommand(env.NewDeleteEnvCommand())
envCmd.AddCommand(env.NewSyncEnvCommand())
cmd.AddCommand(envCmd)
return cmd
}
-38
View File
@@ -1,38 +0,0 @@
package create
import "github.com/spf13/cobra"
// NewCreateCommand creates the create parent command with all subcommands
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create",
Short: "Create a new application",
Long: `Create a new application from various sources.
Available source types:
public Create from a public git repository
github Create from a private repository using GitHub App
deploy-key Create from a private repository using SSH deploy key
dockerfile Create from a custom Dockerfile
dockerimage Create from a pre-built Docker image
Examples:
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--git-repository "https://github.com/user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--github-app-uuid <uuid> --git-repository "user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--docker-registry-image-name "nginx:latest" --ports-exposes 80`,
}
// Add all create subcommands
cmd.AddCommand(NewPublicCommand())
cmd.AddCommand(NewGitHubCommand())
cmd.AddCommand(NewDeployKeyCommand())
cmd.AddCommand(NewDockerfileCommand())
cmd.AddCommand(NewDockerImageCommand())
return cmd
}
-152
View File
@@ -1,152 +0,0 @@
package create
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeployKeyCommand returns the create deploy-key application command
func NewDeployKeyCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deploy-key",
Short: "Create an application from a private repository using SSH deploy key",
Long: `Create a new application from a private git repository using SSH deploy key authentication.
Use 'coolify privatekeys list' to find your private key UUID.
Examples:
coolify app create deploy-key --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--private-key-uuid <uuid> --git-repository "git@github.com:owner/repo.git" --git-branch main \
--build-pack nixpacks --ports-exposes 3000
coolify app create deploy-key --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--private-key-uuid <uuid> --git-repository "git@gitlab.com:owner/repo.git" --git-branch main \
--build-pack dockerfile --ports-exposes 8080 --instant-deploy`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get required flags
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
privateKeyUUID, _ := cmd.Flags().GetString("private-key-uuid")
gitRepository, _ := cmd.Flags().GetString("git-repository")
gitBranch, _ := cmd.Flags().GetString("git-branch")
buildPack, _ := cmd.Flags().GetString("build-pack")
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
// Validate required fields
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if privateKeyUUID == "" {
return fmt.Errorf("--private-key-uuid is required")
}
if gitRepository == "" || gitBranch == "" {
return fmt.Errorf("--git-repository and --git-branch are required")
}
if buildPack == "" || portsExposes == "" {
return fmt.Errorf("--build-pack and --ports-exposes are required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.ApplicationCreateDeployKeyRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
PrivateKeyUUID: privateKeyUUID,
GitRepository: gitRepository,
GitBranch: gitBranch,
BuildPack: buildPack,
PortsExposes: portsExposes,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Optional fields
setOptionalStringFlag(cmd, "name", &req.Name)
setOptionalStringFlag(cmd, "description", &req.Description)
setOptionalStringFlag(cmd, "domains", &req.Domains)
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.CreateDeployKey(ctx, req)
if err != nil {
return fmt.Errorf("failed to create 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)
},
}
// Required flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("private-key-uuid", "", "Private key UUID (required)")
cmd.Flags().String("git-repository", "", "Git repository SSH URL, e.g., 'git@github.com:owner/repo.git' (required)")
cmd.Flags().String("git-branch", "", "Git branch (required)")
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
// Optional flags
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("domains", "", "Domain(s) for the application")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("build-command", "", "Custom build command")
cmd.Flags().String("start-command", "", "Custom start command")
cmd.Flags().String("install-command", "", "Custom install command")
cmd.Flags().String("base-directory", "", "Base directory for the application")
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
cmd.Flags().String("limits-cpus", "", "CPU limit")
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
-120
View File
@@ -1,120 +0,0 @@
package create
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDockerfileCommand returns the create dockerfile application command
func NewDockerfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dockerfile",
Short: "Create an application from a custom Dockerfile",
Long: `Create a new application from a custom Dockerfile content.
Examples:
coolify app create dockerfile --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--dockerfile "FROM node:18\nWORKDIR /app\nCOPY . .\nRUN npm install\nCMD [\"npm\", \"start\"]"
coolify app create dockerfile --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--dockerfile "$(cat Dockerfile)" --ports-exposes 3000 --instant-deploy`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get required flags
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
dockerfile, _ := cmd.Flags().GetString("dockerfile")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
// Validate required fields
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if dockerfile == "" {
return fmt.Errorf("--dockerfile is required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.ApplicationCreateDockerfileRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
Dockerfile: dockerfile,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Optional fields
setOptionalStringFlag(cmd, "name", &req.Name)
setOptionalStringFlag(cmd, "description", &req.Description)
setOptionalStringFlag(cmd, "domains", &req.Domains)
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
setOptionalStringFlag(cmd, "ports-exposes", &req.PortsExposes)
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.CreateDockerfile(ctx, req)
if err != nil {
return fmt.Errorf("failed to create 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)
},
}
// Required flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("dockerfile", "", "Dockerfile content (required)")
// Optional flags
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("domains", "", "Domain(s) for the application")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080'")
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
cmd.Flags().String("limits-cpus", "", "CPU limit")
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
-127
View File
@@ -1,127 +0,0 @@
package create
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDockerImageCommand returns the create dockerimage application command
func NewDockerImageCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dockerimage",
Short: "Create an application from a pre-built Docker image",
Long: `Create a new application from a pre-built Docker image from a registry.
Examples:
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--docker-registry-image-name "nginx:latest" --ports-exposes 80
coolify app create dockerimage --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--docker-registry-image-name "ghcr.io/myorg/myapp" --docker-registry-image-tag "v1.0.0" \
--ports-exposes 3000 --domains "myapp.example.com" --instant-deploy`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get required flags
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
dockerRegistryImageName, _ := cmd.Flags().GetString("docker-registry-image-name")
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
// Validate required fields
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if dockerRegistryImageName == "" {
return fmt.Errorf("--docker-registry-image-name is required")
}
if portsExposes == "" {
return fmt.Errorf("--ports-exposes is required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.ApplicationCreateDockerImageRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
DockerRegistryImageName: dockerRegistryImageName,
PortsExposes: portsExposes,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Optional fields
setOptionalStringFlag(cmd, "name", &req.Name)
setOptionalStringFlag(cmd, "description", &req.Description)
setOptionalStringFlag(cmd, "domains", &req.Domains)
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
setOptionalStringFlag(cmd, "docker-registry-image-tag", &req.DockerRegistryImageTag)
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.CreateDockerImage(ctx, req)
if err != nil {
return fmt.Errorf("failed to create 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)
},
}
// Required flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("docker-registry-image-name", "", "Docker image name from registry (required)")
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '80' or '80,443' (required)")
// Optional flags
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("domains", "", "Domain(s) for the application")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("docker-registry-image-tag", "", "Docker image tag (defaults to 'latest')")
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
cmd.Flags().String("limits-cpus", "", "CPU limit")
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
-153
View File
@@ -1,153 +0,0 @@
package create
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewGitHubCommand returns the create github application command
func NewGitHubCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "github",
Short: "Create an application from a private repository using GitHub App",
Long: `Create a new application from a private git repository using GitHub App authentication.
Use 'coolify github list' to find your GitHub App UUID.
Use 'coolify github repos <app-uuid>' to list accessible repositories.
Examples:
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--github-app-uuid <uuid> --git-repository "owner/repo" --git-branch main \
--build-pack nixpacks --ports-exposes 3000
coolify app create github --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--github-app-uuid <uuid> --git-repository "owner/repo" --git-branch main \
--build-pack dockerfile --ports-exposes 8080 --instant-deploy`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get required flags
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
gitHubAppUUID, _ := cmd.Flags().GetString("github-app-uuid")
gitRepository, _ := cmd.Flags().GetString("git-repository")
gitBranch, _ := cmd.Flags().GetString("git-branch")
buildPack, _ := cmd.Flags().GetString("build-pack")
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
// Validate required fields
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if gitHubAppUUID == "" {
return fmt.Errorf("--github-app-uuid is required")
}
if gitRepository == "" || gitBranch == "" {
return fmt.Errorf("--git-repository and --git-branch are required")
}
if buildPack == "" || portsExposes == "" {
return fmt.Errorf("--build-pack and --ports-exposes are required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.ApplicationCreateGitHubAppRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
GitHubAppUUID: gitHubAppUUID,
GitRepository: gitRepository,
GitBranch: gitBranch,
BuildPack: buildPack,
PortsExposes: portsExposes,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Optional fields
setOptionalStringFlag(cmd, "name", &req.Name)
setOptionalStringFlag(cmd, "description", &req.Description)
setOptionalStringFlag(cmd, "domains", &req.Domains)
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.CreateGitHubApp(ctx, req)
if err != nil {
return fmt.Errorf("failed to create 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)
},
}
// Required flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("github-app-uuid", "", "GitHub App UUID (required)")
cmd.Flags().String("git-repository", "", "Git repository in format 'owner/repo' (required)")
cmd.Flags().String("git-branch", "", "Git branch (required)")
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
// Optional flags
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("domains", "", "Domain(s) for the application")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("build-command", "", "Custom build command")
cmd.Flags().String("start-command", "", "Custom start command")
cmd.Flags().String("install-command", "", "Custom install command")
cmd.Flags().String("base-directory", "", "Base directory for the application")
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
cmd.Flags().String("limits-cpus", "", "CPU limit")
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
-158
View File
@@ -1,158 +0,0 @@
package create
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewPublicCommand returns the create public application command
func NewPublicCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "public",
Short: "Create an application from a public git repository",
Long: `Create a new application from a public git repository.
Examples:
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--git-repository "https://github.com/user/repo" --git-branch main --build-pack nixpacks --ports-exposes 3000
coolify app create public --server-uuid <uuid> --project-uuid <uuid> --environment-name production \
--git-repository "https://github.com/user/repo" --git-branch main --build-pack dockerfile --ports-exposes 8080 \
--instant-deploy --domains "myapp.example.com"`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get required flags
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
gitRepository, _ := cmd.Flags().GetString("git-repository")
gitBranch, _ := cmd.Flags().GetString("git-branch")
buildPack, _ := cmd.Flags().GetString("build-pack")
portsExposes, _ := cmd.Flags().GetString("ports-exposes")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
// Validate required fields
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if gitRepository == "" || gitBranch == "" {
return fmt.Errorf("--git-repository and --git-branch are required")
}
if buildPack == "" || portsExposes == "" {
return fmt.Errorf("--build-pack and --ports-exposes are required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.ApplicationCreatePublicRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
GitRepository: gitRepository,
GitBranch: gitBranch,
BuildPack: buildPack,
PortsExposes: portsExposes,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Optional fields
setOptionalStringFlag(cmd, "name", &req.Name)
setOptionalStringFlag(cmd, "description", &req.Description)
setOptionalStringFlag(cmd, "domains", &req.Domains)
setOptionalStringFlag(cmd, "git-commit-sha", &req.GitCommitSHA)
setOptionalStringFlag(cmd, "destination-uuid", &req.DestinationUUID)
setOptionalStringFlag(cmd, "build-command", &req.BuildCommand)
setOptionalStringFlag(cmd, "start-command", &req.StartCommand)
setOptionalStringFlag(cmd, "install-command", &req.InstallCommand)
setOptionalStringFlag(cmd, "base-directory", &req.BaseDirectory)
setOptionalStringFlag(cmd, "publish-directory", &req.PublishDirectory)
setOptionalStringFlag(cmd, "ports-mappings", &req.PortsMappings)
setOptionalStringFlag(cmd, "limits-cpus", &req.LimitsCPUs)
setOptionalStringFlag(cmd, "limits-memory", &req.LimitsMemory)
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
app, err := appSvc.CreatePublic(ctx, req)
if err != nil {
return fmt.Errorf("failed to create 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)
},
}
// Required flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("git-repository", "", "Git repository URL (required)")
cmd.Flags().String("git-branch", "", "Git branch (required)")
cmd.Flags().String("build-pack", "", "Build pack: nixpacks, static, dockerfile, dockercompose (required)")
cmd.Flags().String("ports-exposes", "", "Exposed ports, e.g., '3000' or '3000,8080' (required)")
// Optional flags
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("domains", "", "Domain(s) for the application")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().String("git-commit-sha", "", "Specific commit SHA to deploy")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("build-command", "", "Custom build command")
cmd.Flags().String("start-command", "", "Custom start command")
cmd.Flags().String("install-command", "", "Custom install command")
cmd.Flags().String("base-directory", "", "Base directory for the application")
cmd.Flags().String("publish-directory", "", "Publish directory for static builds")
cmd.Flags().String("ports-mappings", "", "Port mappings (host:container)")
cmd.Flags().String("limits-cpus", "", "CPU limit")
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
// Helper functions for optional flags
func setOptionalStringFlag(cmd *cobra.Command, flagName string, target **string) {
if cmd.Flags().Changed(flagName) {
val, _ := cmd.Flags().GetString(flagName)
*target = &val
}
}
func setOptionalBoolFlag(cmd *cobra.Command, flagName string, target **bool) {
if cmd.Flags().Changed(flagName) {
val, _ := cmd.Flags().GetBool(flagName)
*target = &val
}
}
-57
View File
@@ -1,57 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <uuid>",
Short: "Delete an application",
Long: `Delete an application. This action cannot be undone.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
force, _ := cmd.Flags().GetBool("force")
if !force {
var response string
fmt.Printf("Are you sure you want to delete application %s? This cannot be undone. (yes/no): ", uuid)
_, err := fmt.Scanln(&response)
if err != nil {
return fmt.Errorf("failed to read input: %w", err)
}
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
client, err := cli.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
},
}
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
return cmd
}
-182
View File
@@ -1,182 +0,0 @@
package application
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deployments",
Short: "Deployment related commands for an application",
Long: `Manage deployments for a specific application. List deployments or view deployment logs.`,
}
cmd.AddCommand(NewListDeploymentsCommand())
cmd.AddCommand(NewLogsDeploymentsCommand())
return cmd
}
func NewListDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list <app-uuid>",
Short: "List all deployments for an application",
Long: `Retrieve a list of all deployments for a specific application.`,
Args: cli.ExactArgs(1, "<app-uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
appUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
deploySvc := service.NewDeploymentService(client)
deployments, err := deploySvc.ListByApplication(ctx, appUUID)
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 err
}
return formatter.Format(deployments)
},
}
return cmd
}
func NewLogsDeploymentsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logs <app-uuid> [deployment-uuid]",
Short: "Get deployment logs for an application",
Long: `Retrieve deployment logs for a specific application or deployment.
If only app-uuid is provided, retrieves logs from the latest deployment.
If deployment-uuid is also provided, retrieves logs for that specific deployment.
Use --follow to continuously stream new logs.`,
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := context.Background()
appUUID := args[0]
var deploymentUUID string
if len(args) == 2 {
deploymentUUID = args[1]
}
client, err := cli.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")
debugLogs, _ := cmd.Flags().GetBool("debuglogs")
format, _ := cmd.Flags().GetString("format")
deploySvc := service.NewDeploymentService(client)
// Function to get logs based on whether we have a deployment UUID
// Returns raw or formatted based on format flag
getLogs := func() (string, error) {
if deploymentUUID != "" {
return deploySvc.GetLogsByDeploymentWithFormat(ctx, deploymentUUID, debugLogs, format)
}
// Get logs from the latest deployment
// Use take=1 internally to efficiently fetch only the most recent deployment
return deploySvc.GetLogsByApplicationWithFormat(ctx, appUUID, 1, debugLogs, format)
}
if !follow {
logs, err := getLogs()
if err != nil {
return fmt.Errorf("failed to get deployment logs: %w", err)
}
// Apply line limit if specified (only for text output)
if lines > 0 && format == "table" {
logs = limitLogLines(logs, lines)
}
fmt.Print(logs)
return nil
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
lastLogs := ""
logs, err := getLogs()
if err != nil {
return fmt.Errorf("failed to get deployment logs: %w", err)
}
fmt.Print(logs)
lastLogs = logs
for {
select {
case <-sigChan:
fmt.Println("\nStopping log follow...")
return nil
case <-ticker.C:
logs, err := getLogs()
if err != nil {
continue
}
if logs != lastLogs {
if len(logs) > len(lastLogs) && strings.HasPrefix(logs, lastLogs) {
fmt.Print(logs[len(lastLogs):])
} else {
fmt.Print(logs)
}
lastLogs = logs
}
}
}
},
}
cmd.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
cmd.Flags().Bool("debuglogs", false, "Show debug logs (includes hidden commands and internal operations)")
return cmd
}
// limitLogLines limits the output to the last N lines
func limitLogLines(logs string, n int) string {
if n <= 0 {
return logs
}
// Trim trailing newline to avoid empty element at the end
logs = strings.TrimRight(logs, "\n")
lines := strings.Split(logs, "\n")
// If we have fewer lines than requested, return all
if len(lines) <= n {
return logs + "\n"
}
// Get the last N lines
lastLines := lines[len(lines)-n:]
return strings.Join(lastLines, "\n") + "\n"
}
-84
View File
@@ -1,84 +0,0 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewCreateEnvCommand() *cobra.Command {
cmd := &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: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
client, err := cli.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")
isRuntime, _ := cmd.Flags().GetBool("runtime")
if key == "" {
return fmt.Errorf("--key is required")
}
if value == "" {
return fmt.Errorf("--value is required")
}
req := &models.EnvironmentVariableCreateRequest{
Key: key,
Value: value,
}
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
}
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.CreateEnv(ctx, appUUID, 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
},
}
cmd.Flags().String("key", "", "Environment variable key (required)")
cmd.Flags().String("value", "", "Environment variable value (required)")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("preview", false, "Available in preview deployments")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
return cmd
}
-59
View File
@@ -1,59 +0,0 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteEnvCommand() *cobra.Command {
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: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
envUUID := args[1]
client, err := cli.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): ")
_, err := fmt.Scanln(&response)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
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
},
}
deleteEnvCmd.Flags().Bool("force", false, "Skip confirmation prompt")
return deleteEnvCmd
}
-60
View File
@@ -1,60 +0,0 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewGetEnvCommand() *cobra.Command {
cmd := &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: cli.ExactArgs(2, "<app_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
envUUIDOrKey := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
appSvc := service.NewApplicationService(client)
// First try to get by the identifier directly
env, err := appSvc.GetEnv(ctx, appUUID, envUUIDOrKey)
if err != nil {
return fmt.Errorf("failed to get environment variable: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
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)
},
}
return cmd
}
-87
View File
@@ -1,87 +0,0 @@
package env
import (
"fmt"
"sort"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewListEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list <app_uuid>",
Short: "List all environment variables for an application",
Long: `List all environment variables for a specific application. By default, only non-preview environment variables are shown. Use --preview to show preview environment variables instead, or --all to show all variables (non-preview first, then preview).`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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)
}
// Filter by preview/all flags
showAll, _ := cmd.Flags().GetBool("all")
showPreview, _ := cmd.Flags().GetBool("preview")
if showAll {
// Sort: non-preview first, then preview
sort.SliceStable(envs, func(i, j int) bool {
if envs[i].IsPreview != envs[j].IsPreview {
return !envs[i].IsPreview // non-preview (false) comes before preview (true)
}
return false // maintain original order within groups
})
} else {
// Filter by preview flag
var filtered []models.EnvironmentVariable
for _, env := range envs {
if env.IsPreview == showPreview {
filtered = append(filtered, env)
}
}
envs = filtered
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
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)
},
}
cmd.Flags().Bool("preview", false, "Show preview environment variables instead of regular ones")
cmd.Flags().Bool("all", false, "Show all environment variables (non-preview first, then preview)")
return cmd
}
-159
View File
@@ -1,159 +0,0 @@
package env
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/parser"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewSyncEnvCommand() *cobra.Command {
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: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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")
isRuntime, _ := cmd.Flags().GetBool("runtime")
// 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
}
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
// 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
},
}
syncEnvCmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
syncEnvCmd.Flags().Bool("build-time", true, "Make all variables available at build time (default: true)")
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)")
syncEnvCmd.Flags().Bool("runtime", true, "Make all variables available at runtime (default: true)")
return syncEnvCmd
}
-90
View File
@@ -1,90 +0,0 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewUpdateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. UUID is the application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
return err
}
req := &models.EnvironmentVariableUpdateRequest{}
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
}
if cmd.Flags().Changed("runtime") {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil {
return fmt.Errorf("--key is required")
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
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
},
}
cmd.Flags().String("key", "", "New environment variable key")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("preview", false, "Available in preview deployments")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
return cmd
}
-47
View File
@@ -1,47 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <uuid>",
Short: "Get application details by UUID",
Long: `Retrieve detailed information about a specific application.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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)
},
}
}
-70
View File
@@ -1,70 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all applications",
Long: `List all applications in Coolify.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.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)
},
}
}
-86
View File
@@ -1,86 +0,0 @@
package application
import (
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewLogsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "logs <uuid>",
Short: "Get application logs",
Long: `Retrieve logs for an application. Use --follow to continuously stream new logs.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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 {
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
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
lastLogs := ""
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
for {
select {
case <-sigChan:
fmt.Println("\nStopping log follow...")
return nil
case <-ticker.C:
resp, err := appSvc.Logs(ctx, uuid, lines)
if err != nil {
continue
}
if resp.Logs != lastLogs {
if len(resp.Logs) > len(lastLogs) && strings.HasPrefix(resp.Logs, lastLogs) {
fmt.Print(resp.Logs[len(lastLogs):])
} else {
fmt.Print(resp.Logs)
}
lastLogs = resp.Logs
}
}
}
},
}
cmd.Flags().IntP("lines", "n", 100, "Number of log lines to retrieve")
cmd.Flags().BoolP("follow", "f", false, "Follow log output (like tail -f)")
return cmd
}
-37
View File
@@ -1,37 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewRestartCommand() *cobra.Command {
return &cobra.Command{
Use: "restart <uuid>",
Short: "Restart an application",
Long: `Restart a running application.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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
},
}
}
-48
View File
@@ -1,48 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewStartCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "start <uuid>",
Aliases: []string{"deploy"},
Short: "Start an application",
Long: `Start an application (initiates a deployment).`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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
},
}
cmd.Flags().Bool("force", false, "Force rebuild")
cmd.Flags().Bool("instant-deploy", false, "Instant deploy (skip queuing)")
return cmd
}
-37
View File
@@ -1,37 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <uuid>",
Short: "Stop an application",
Long: `Stop a running application.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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
},
}
}
-161
View File
@@ -1,161 +0,0 @@
package application
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <uuid>",
Short: "Update application configuration",
Long: `Update configuration for a specific application. Only specified fields will be updated.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
req := models.ApplicationUpdateRequest{}
hasUpdates := false
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
}
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
}
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
}
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
}
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)
},
}
cmd.Flags().String("name", "", "Application name")
cmd.Flags().String("description", "", "Application description")
cmd.Flags().String("git-branch", "", "Git branch")
cmd.Flags().String("git-repository", "", "Git repository URL")
cmd.Flags().String("domains", "", "Domains (comma-separated)")
cmd.Flags().String("build-command", "", "Build command")
cmd.Flags().String("start-command", "", "Start command")
cmd.Flags().String("install-command", "", "Install command")
cmd.Flags().String("base-directory", "", "Base directory")
cmd.Flags().String("publish-directory", "", "Publish directory")
cmd.Flags().String("dockerfile", "", "Dockerfile content")
cmd.Flags().String("docker-image", "", "Docker image name")
cmd.Flags().String("docker-tag", "", "Docker image tag")
cmd.Flags().String("ports-exposes", "", "Exposed ports")
cmd.Flags().String("ports-mappings", "", "Port mappings")
cmd.Flags().Bool("health-check-enabled", false, "Enable health check")
cmd.Flags().String("health-check-path", "", "Health check path")
return cmd
}
+46
View File
@@ -0,0 +1,46 @@
package ask
import (
"bufio"
"fmt"
"os"
"strings"
)
func PromptYesOrNo(question string, defaultToYes bool) (bool, error) {
r := bufio.NewReader(os.Stdin)
if defaultToYes {
fmt.Fprintf(os.Stderr, "%s [Y/n]: ", question)
} else {
fmt.Fprintf(os.Stderr, "%s [y/N]: ", question)
}
for {
answer, err := r.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return defaultToYes, err
}
answer = strings.ToLower(strings.TrimSpace(answer))
switch answer {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
case "":
return defaultToYes, nil
}
fmt.Fprintf(os.Stderr, "Please answer with 'y' or 'n': ")
}
}
func PromptString(question string) (string, error) {
r := bufio.NewReader(os.Stdin)
fmt.Fprintf(os.Stderr, "%s: ", question)
answer, err := r.ReadString('\n')
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err)
return "", err
}
return strings.TrimSpace(answer), nil
}
+104
View File
@@ -0,0 +1,104 @@
package cliinit
import (
"errors"
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInit struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliInit {
return &cliInit{
coolify: c,
}
}
var defaultInstances = []coolTypes.Instance{
{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: "",
}, {
Name: "localhost",
Fqdn: "http://localhost:8000",
Token: "",
},
}
func (c *cliInit) NewCommand() *cobra.Command {
generateDefault := false
force := false
cmd := &cobra.Command{
Use: "init",
Example: utils.GetCommandExample(`
%[1]s init
%[1]s init --default
%[1]s init --force
`),
Short: "Initialize a new Coolify CLI configuration file",
Long: `
Initialize Coolify CLI by generating a configuration file in the default directory.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
if c.coolify().Config.JsonExists && !force {
return errors.New("configuration file already exists. Please use instances command to make further modifications or force flag to regenerate a new configuration file")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if generateDefault {
viper.Set("instances", defaultInstances)
cmd.Println("Configuration file generated with default instances, use the instances command to make further modifications.")
return c.coolify().Save()
}
// Create a channel to receive the instances
result := make(chan []coolTypes.Instance)
p := tea.NewProgram(newInitModel(result))
// Create a done channel to signal when the program is finished
done := make(chan struct{})
var programErr error
// Run the program in a goroutine
go func() {
_, programErr = p.Run()
close(done)
}()
// Wait for either the instances or context cancellation
var instances []coolTypes.Instance
select {
case instances = <-result:
case <-cmd.Context().Done():
return fmt.Errorf("operation cancelled")
case <-done:
if programErr != nil {
return fmt.Errorf("program error: %v", programErr)
}
return fmt.Errorf("program exited without saving instances")
}
viper.Set("instances", instances)
return c.coolify().Save()
},
}
flags := cmd.Flags()
flags.BoolVarP(&generateDefault, "default", "d", false, "Generate a default configuration file (non-interactive)")
flags.BoolVarP(&force, "force", "f", false, "Force the generation of a new configuration file")
return cmd
}
+448
View File
@@ -0,0 +1,448 @@
package cliinit
import (
"errors"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/tui"
)
var (
checkboxStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
checked = checkboxStyle.Render("[x]")
unchecked = checkboxStyle.Render("[ ]")
goldStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true)
)
// initKeyMap defines keybindings for the initialization form
type initKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Space key.Binding
Enter key.Binding
Paste key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k initKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k initKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Space, k.Enter, k.Paste, k.Help}, // second column
{k.Quit}, // third column
}
}
var initKeys = initKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("tab", "next field"),
),
Space: key.NewBinding(
key.WithKeys(" "),
key.WithHelp("space", "toggle checkbox"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "continue"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type initModel struct {
instances []coolTypes.Instance
width int
height int
focus int
err error
useCloud bool
useSelfHost bool
cloudToken textinput.Model
selfHostName textinput.Model
selfHostFqdn textinput.Model
selfHostToken textinput.Model
result chan<- []coolTypes.Instance
step int // Current step in the initialization process
tick int // For rainbow effect
keys initKeyMap
help help.Model
}
func newInitModel(result chan<- []coolTypes.Instance) initModel {
cloudToken := textinput.New()
cloudToken.Placeholder = "Enter your Coolify Cloud token"
cloudToken.Prompt = "Cloud Token: "
cloudToken.PromptStyle = tui.FocusedStyle
cloudToken.TextStyle = tui.FocusedStyle
cloudToken.Validate = tui.ValidateNotEmpty
selfHostName := textinput.New()
selfHostName.Placeholder = "Enter name for self-hosted instance"
selfHostName.Prompt = "Name: "
selfHostName.PromptStyle = tui.FocusedStyle
selfHostName.TextStyle = tui.FocusedStyle
selfHostName.Validate = tui.ValidateNotEmpty
selfHostFqdn := textinput.New()
selfHostFqdn.Placeholder = "Enter FQDN for self-hosted instance"
selfHostFqdn.Prompt = "FQDN: "
selfHostFqdn.PromptStyle = tui.FocusedStyle
selfHostFqdn.TextStyle = tui.FocusedStyle
selfHostFqdn.Validate = tui.ValidateFQDN
selfHostToken := textinput.New()
selfHostToken.Placeholder = "Enter token for self-hosted instance"
selfHostToken.Prompt = "Token: "
selfHostToken.PromptStyle = tui.FocusedStyle
selfHostToken.TextStyle = tui.FocusedStyle
selfHostToken.Validate = tui.ValidateNotEmpty
return initModel{
instances: make([]coolTypes.Instance, 0),
focus: 0,
result: result,
step: 0,
cloudToken: cloudToken,
selfHostName: selfHostName,
selfHostFqdn: selfHostFqdn,
selfHostToken: selfHostToken,
keys: initKeys,
help: help.New(),
}
}
func (m initModel) Init() tea.Cmd {
return textinput.Blink
}
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
return m, nil
case key.Matches(msg, m.keys.Space):
// Space toggles checkbox when on step 0 or 2
switch m.step {
case 0:
m.useCloud = !m.useCloud
return m, nil
case 2:
m.useSelfHost = !m.useSelfHost
return m, nil
}
case key.Matches(msg, m.keys.Enter):
switch m.step {
case 0:
// Enter handles progression
if m.useCloud {
m.step++
m.focus = 1
m.cloudToken.Focus()
} else {
m.step += 2
m.focus = 2
}
case 1:
if m.useCloud {
// Check for validation errors
if m.cloudToken.Err != nil {
m.err = m.cloudToken.Err
return m, nil
}
// Manual validation in case field hasn't been edited
if m.cloudToken.Value() == "" {
m.err = errors.New("token is required when using Coolify Cloud")
return m, nil
}
m.step++
m.focus = 2
m.cloudToken.Blur()
}
case 2:
// Enter handles progression
if m.useSelfHost {
m.step++
m.focus = 3
m.selfHostName.Focus()
} else {
// If self-hosted is false, build instances and quit
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: m.cloudToken.Value(),
})
}
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
}
case 3:
cloudToken := strings.TrimSpace(m.cloudToken.Value())
if m.useSelfHost {
// Check for validation errors
if m.selfHostName.Err != nil || m.selfHostFqdn.Err != nil || m.selfHostToken.Err != nil {
m.err = errors.New("please fix all field errors before submitting")
return m, nil
}
selfHostName := strings.TrimSpace(m.selfHostName.Value())
selfHostFqdn := strings.TrimSpace(m.selfHostFqdn.Value())
selfHostToken := strings.TrimSpace(m.selfHostToken.Value())
// Manual validation in case fields haven't been edited
if selfHostName == "" {
m.err = errors.New("name is required for self-hosted instance")
return m, nil
}
if selfHostFqdn == "" {
m.err = errors.New("FQDN is required for self-hosted instance")
return m, nil
}
if selfHostToken == "" {
m.err = errors.New("token is required for self-hosted instance")
return m, nil
}
// Build instances array
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: cloudToken,
})
}
m.instances = append(m.instances, coolTypes.Instance{
Name: selfHostName,
Default: !m.useCloud,
Fqdn: selfHostFqdn,
Token: selfHostToken,
})
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
} else {
// If self-hosted is false, build instances and quit
if m.useCloud {
m.instances = append(m.instances, coolTypes.Instance{
Name: "cloud",
Default: true,
Fqdn: "https://app.coolify.io",
Token: cloudToken,
})
}
// Send instances back to command
if m.result != nil {
m.result <- m.instances
}
return m, tea.Quit
}
}
case key.Matches(msg, m.keys.Up):
// Only allow up/down navigation when multiple items are visible
if m.step == 3 && m.useSelfHost {
m.focus--
if m.focus < 3 {
m.focus = 5
}
m.updateFocus()
}
case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab):
// Only allow up/down navigation when multiple items are visible
if m.step == 3 && m.useSelfHost {
m.focus++
if m.focus > 5 {
m.focus = 3
}
m.updateFocus()
}
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
}
// Handle text input updates
if m.step == 1 && m.focus == 1 {
m.cloudToken, cmd = m.cloudToken.Update(msg)
cmds = append(cmds, cmd)
} else if m.step == 3 {
switch m.focus {
case 3:
m.selfHostName, cmd = m.selfHostName.Update(msg)
cmds = append(cmds, cmd)
case 4:
m.selfHostFqdn, cmd = m.selfHostFqdn.Update(msg)
cmds = append(cmds, cmd)
case 5:
m.selfHostToken, cmd = m.selfHostToken.Update(msg)
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
}
func (m *initModel) updateFocus() {
// Blur all inputs
m.cloudToken.Blur()
m.selfHostName.Blur()
m.selfHostFqdn.Blur()
m.selfHostToken.Blur()
// Focus the selected input
switch m.focus {
case 1:
m.cloudToken.Focus()
case 3:
m.selfHostName.Focus()
case 4:
m.selfHostFqdn.Focus()
case 5:
m.selfHostToken.Focus()
}
}
func (m initModel) View() string {
if m.width == 0 {
return "loading..."
}
var s strings.Builder
// Title
s.WriteString("Initialize Coolify CLI\n\n")
// Step 1: Cloud question
if m.step == 0 {
cloudStyle := tui.BlurredStyle
if m.focus == 0 {
cloudStyle = tui.FocusedStyle
}
s.WriteString(cloudStyle.Render("Do you use "))
s.WriteString(goldStyle.Render("Coolify Cloud?"))
s.WriteString(" ")
if m.useCloud {
s.WriteString(checked)
} else {
s.WriteString(unchecked)
}
s.WriteString("\n")
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
}
// Step 2: Cloud token input
if m.step == 1 && m.useCloud {
s.WriteString(m.cloudToken.View())
if m.cloudToken.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.cloudToken.Err.Error()))
}
s.WriteString("\n")
}
// Step 3: Self-hosted question
if m.step == 2 {
selfHostStyle := tui.BlurredStyle
if m.focus == 2 {
selfHostStyle = tui.FocusedStyle
}
s.WriteString(selfHostStyle.Render("Add self-hosted instance"))
s.WriteString(" ")
if m.useSelfHost {
s.WriteString(checked)
} else {
s.WriteString(unchecked)
}
s.WriteString("\n")
s.WriteString(tui.BlurredStyle.Render("Hint: use spacebar to toggle checkbox\n"))
}
// Step 4: Self-hosted inputs
if m.step == 3 && m.useSelfHost {
// Name input
s.WriteString(m.selfHostName.View())
if m.selfHostName.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostName.Err.Error()))
}
s.WriteString("\n\n")
// FQDN input
s.WriteString(m.selfHostFqdn.View())
if m.selfHostFqdn.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostFqdn.Err.Error()))
}
s.WriteString("\n\n")
// Token input
s.WriteString(m.selfHostToken.View())
if m.selfHostToken.Err != nil {
// Display validation error next to input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(m.selfHostToken.Err.Error()))
}
s.WriteString("\n")
}
// Help view
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keys))
// Error message
if m.err != nil {
s.WriteString("\n\n")
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
}
return s.String()
}
+128
View File
@@ -0,0 +1,128 @@
package cliinstances
import (
"errors"
"fmt"
"slices"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstances) newAddCommand() *cobra.Command {
force := false
isNewDefault := false
cmd := &cobra.Command{
Use: "add [name] [fqdn] [token]",
Example: utils.GetCommandExample(`
%[1]s instances add MyInstance https://my.instance.tld 1234
%[1]s instances add AnotherInstance https://another.instance.tld 5678 --default
%[1]s instances add MyInstance https://my.instance.tld 91011 --force
%[1]s instances add # Interactive mode
`),
Short: "Add a new instance",
Long: `
Add a new instance to the CLI configuration file.
If no arguments are provided, an interactive form will be shown.
`,
Aliases: []string{"create"},
SilenceUsage: true,
Args: cobra.RangeArgs(0, 3),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return c.runInteractiveMode(cmd, force, isNewDefault)
} else if len(args) != 3 {
return errors.New("command requires either 0 arguments (interactive mode) or exactly 3 arguments (name, fqdn, token)")
}
return c.runNonInteractiveMode(args, force, isNewDefault)
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Force overwrite existing instance with the same name")
flags.BoolVarP(&isNewDefault, "default", "d", false, "Set this instance as the default instance")
return cmd
}
func (c *cliInstances) runInteractiveMode(cmd *cobra.Command, force, isDefault bool) error {
result := make(chan coolTypes.Instance)
p := tea.NewProgram(newAddModel(result, force, isDefault))
// Create a done channel to signal when the program is finished
done := make(chan struct{})
var programErr error
// Run the program in a goroutine
go func() {
_, programErr = p.Run()
close(done)
}()
// Wait for either the instance or context cancellation
var instance coolTypes.Instance
select {
case instance = <-result:
case <-cmd.Context().Done():
return fmt.Errorf("operation cancelled")
case <-done:
if programErr != nil {
return fmt.Errorf("program error: %v", programErr)
}
return fmt.Errorf("program exited without saving instance")
}
// Check for existing instance with same name
for i, existing := range c.instances {
if existing.Name == instance.Name {
if !force {
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
}
c.instances = slices.Delete(c.instances, i, i+1)
break
}
}
if isDefault {
for i := range c.instances {
c.instances[i].Default = false
}
}
c.instances = append(c.instances, instance)
viper.Set("instances", c.instances)
return c.coolify().Save()
}
func (c *cliInstances) runNonInteractiveMode(args []string, force, isNewDefault bool) error {
// Check for existing instance with same name
for i, instance := range c.instances {
if instance.Name == args[0] {
if !force {
return errors.New("instance with the same name already exists. Use the force flag to overwrite or instances set to modify individual attributes")
}
c.instances = slices.Delete(c.instances, i, i+1)
break
}
}
newInstance := coolTypes.Instance{
Name: args[0],
Fqdn: args[1],
Token: args[2],
Default: isNewDefault,
}
if isNewDefault {
for i := range c.instances {
c.instances[i].Default = false
}
}
c.instances = append(c.instances, newInstance)
viper.Set("instances", c.instances)
return c.coolify().Save()
}
+297
View File
@@ -0,0 +1,297 @@
package cliinstances
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/tui"
)
// addKeyMap defines keybindings for the add instance form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Paste key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Paste, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type addModel struct {
inputs []textinput.Model
focus int
err error
instance coolTypes.Instance
width int
height int
result chan<- coolTypes.Instance
force bool
isDefault bool
keys addKeyMap
help help.Model
}
func newAddModel(result chan<- coolTypes.Instance, force, isDefault bool) addModel {
// Create text inputs
inputs := make([]textinput.Model, 3)
labels := []string{"Name", "FQDN", "Token"}
for i, label := range labels {
input := textinput.New()
input.Placeholder = fmt.Sprintf("Enter instance %s", label)
input.Prompt = fmt.Sprintf("%s: ", label)
input.PromptStyle = tui.FocusedStyle
input.TextStyle = tui.FocusedStyle
// Set up validation for each input type
switch label {
case "Name":
input.Validate = tui.ValidateNotEmpty
case "FQDN":
input.Validate = tui.ValidateFQDN
case "Token":
input.Validate = tui.ValidateNotEmpty
}
// Focus first input by default
if i == 0 {
input.Focus()
}
inputs[i] = input
}
return addModel{
inputs: inputs,
focus: 0,
result: result,
force: force,
isDefault: isDefault,
keys: addKeys,
help: help.New(),
}
}
func (m addModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.keys.Enter):
if m.focus == len(m.inputs) {
// Submit - first check if any field has validation errors
for _, input := range m.inputs {
if input.Err != nil {
// Don't proceed if any field has validation errors
m.err = errors.New("please fix all field errors before submitting")
return m, nil
}
}
// Also validate in case fields haven't been edited
if err := m.validateOnSubmit(); err != nil {
m.err = err
return m, nil
}
m.instance = coolTypes.Instance{
Name: strings.TrimSpace(m.inputs[0].Value()),
Fqdn: strings.TrimSpace(m.inputs[1].Value()),
Token: strings.TrimSpace(m.inputs[2].Value()),
Default: m.isDefault,
}
// Return a command to send the instance
return m, func() tea.Msg {
if m.result != nil {
m.result <- m.instance
}
return tea.Quit()
}
} else if m.focus == len(m.inputs)+1 {
// Cancel
return m, tea.Quit
}
// Move to next input
m.focus++
m.updateFocus()
case key.Matches(msg, m.keys.Tab):
if msg.String() == "tab" {
m.focus++
} else {
m.focus--
}
// Wrap around
if m.focus > len(m.inputs)+1 {
m.focus = 0
} else if m.focus < 0 {
m.focus = len(m.inputs) + 1
}
m.updateFocus()
case key.Matches(msg, m.keys.Up):
m.focus--
if m.focus < 0 {
m.focus = len(m.inputs) + 1
}
m.updateFocus()
case key.Matches(msg, m.keys.Down):
m.focus++
if m.focus > len(m.inputs)+1 {
m.focus = 0
}
m.updateFocus()
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
}
// Handle text input updates
if m.focus < len(m.inputs) {
var cmd tea.Cmd
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
cmds = append(cmds, cmd)
}
return m, tea.Batch(cmds...)
}
func (m *addModel) updateFocus() {
// Blur all inputs
for i := range m.inputs {
m.inputs[i].Blur()
}
// Focus current input if it's a text input
if m.focus < len(m.inputs) {
m.inputs[m.focus].Focus()
}
}
// validateOnSubmit handles validation for fields that haven't been edited
func (m addModel) validateOnSubmit() error {
// Trigger validation for all fields
for i, input := range m.inputs {
// If the field hasn't been edited and is empty, it hasn't triggered validation yet
switch i {
case 0:
return tui.ValidateNotEmpty(input.Value())
case 1:
return tui.ValidateFQDN(input.Value())
case 2:
return tui.ValidateNotEmpty(input.Value())
}
}
return nil
}
func (m addModel) View() string {
if m.width == 0 {
return "loading..."
}
var s strings.Builder
// Title
s.WriteString("Add New Instance\n\n")
// Input fields with validation errors
for _, input := range m.inputs {
s.WriteString(input.View())
if input.Err != nil {
// Display the validation error next to the input
s.WriteString(" ")
s.WriteString(tui.ErrorStyle.Render(input.Err.Error()))
}
s.WriteString("\n")
}
// Submit and Cancel buttons
submitStyle := tui.BlurredStyle
if m.focus == len(m.inputs) {
submitStyle = tui.FocusedStyle
}
s.WriteString(submitStyle.Render("Submit"))
s.WriteString(" ")
cancelStyle := tui.BlurredStyle
if m.focus == len(m.inputs)+1 {
cancelStyle = tui.FocusedStyle
}
s.WriteString(cancelStyle.Render("Cancel"))
// Help view at the bottom
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keys))
// General form error message (if any)
if m.err != nil {
s.WriteString("\n\n")
s.WriteString(tui.ErrorStyle.Render(m.err.Error()))
}
return s.String()
}
+48
View File
@@ -0,0 +1,48 @@
package cliinstances
import (
cliinstancesset "github.com/coollabsio/cli-coolify/cmd/cliinstances/set"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInstances struct {
coolify runtime.Getter
instances []coolTypes.Instance
}
func (c *cliInstances) runtime() *runtime.Coolify {
return c.coolify()
}
func New(c runtime.Getter) *cliInstances {
return &cliInstances{
coolify: c,
}
}
func (c *cliInstances) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "instances",
Short: "Manage CLI instances",
Aliases: []string{"instance"},
Long: `
Manage CLI instances by adding, removing or setting options for the instance.
`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if instances := viper.Get("instances"); instances != nil {
return viper.UnmarshalKey("instances", &c.instances)
}
return nil
},
}
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(cliinstancesset.New(c.runtime).NewCommand())
return cmd
}
+204
View File
@@ -0,0 +1,204 @@
package cliinstances
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/emoji"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// wrappedInstance implements the FilterableItem interface
type wrappedInstance struct {
instance coolTypes.Instance
}
func (w wrappedInstance) GetFilterValue() string {
return w.instance.Name
}
type filterableListModel struct {
filterableTable *tui.FilterableTable
}
func (c *cliInstances) handleDelete(item tui.FilterableItem) error {
instance := item.(wrappedInstance).instance
// Don't allow deleting default instance without force flag
if instance.Default {
return fmt.Errorf("cannot delete default instance. Use 'instances remove %s --force' instead", instance.Name)
}
// Find and remove the instance from the slice
for i, existing := range c.instances {
if existing.Name == instance.Name {
c.instances = append(c.instances[:i], c.instances[i+1:]...)
break
}
}
// Update viper and save
viper.Set("instances", c.instances)
return c.coolify().Save()
}
func newFilterableListModel(instances []coolTypes.Instance, sensitive bool, initialFilter string, deleteHandler func(tui.FilterableItem) error) *filterableListModel {
columns := []table.Column{
{Title: "Name", Width: 30},
{Title: "URL", Width: 40},
{Title: "Default", Width: 8},
}
// Convert instances to FilterableItems
items := make([]tui.FilterableItem, len(instances))
for i, instance := range instances {
items[i] = wrappedInstance{instance: instance}
}
// Create row builder function
rowBuilder := func(item tui.FilterableItem) table.Row {
instance := item.(wrappedInstance).instance
e := emoji.CrossMark
if instance.Default {
e = emoji.CheckMarkButton
}
return table.Row{
instance.Name,
instance.Fqdn,
e,
}
}
// Create detail view builder function
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
instance := item.(wrappedInstance).instance
var s strings.Builder
addSection := func(title, value string) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
s.WriteString(value + "\n\n")
}
addSection("Name", instance.Name)
addSection("URL", instance.Fqdn)
if sensitive {
addSection("Token", instance.Token)
} else {
addSection("Token", "********")
}
addSection("Default", fmt.Sprintf("%v", instance.Default))
return s.String()
}
ft := tui.NewTableFilter(items, columns, rowBuilder).
WithInitialFilter(initialFilter).
WithDetailView(detailBuilder).
WithDetailHeader("Instance Details").
WithDeleteHandler(deleteHandler)
return &filterableListModel{
filterableTable: ft,
}
}
func (m *filterableListModel) Init() tea.Cmd {
return nil
}
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.filterableTable.Update(msg)
}
func (m *filterableListModel) View() string {
return m.filterableTable.View()
}
func (c *cliInstances) newListCommand() *cobra.Command {
sensitive := false
cmd := &cobra.Command{
Use: "list [name]",
Short: "List all instances",
Long: `
List all instances from the CLI configuration file.
If a name is provided, only instances matching that name will be shown.
`,
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
initialFilter := ""
if len(args) > 0 {
initialFilter = args[0]
}
format, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format: %v", err)
}
// If format is json, output JSON and exit
if format == "json" {
// Filter instances for JSON output
filteredInstances := filterInstances(c.instances, initialFilter)
// If not sensitive, redact tokens
if !sensitive {
filteredInstances = redactTokens(filteredInstances)
}
// Encode directly to JSON using the struct's annotations
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(filteredInstances)
}
// Run interactive UI
p := tea.NewProgram(newFilterableListModel(c.instances, sensitive, initialFilter, c.handleDelete))
_, err = p.Run()
if err != nil {
return fmt.Errorf("program error: %v", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&sensitive, "sensitive", "s", false, "Show sensitive information such as tokens")
return cmd
}
// filterInstances filters instances based on a name filter
func filterInstances(instances []coolTypes.Instance, filter string) []coolTypes.Instance {
if filter == "" {
return instances
}
filtered := make([]coolTypes.Instance, 0)
for _, instance := range instances {
if strings.Contains(strings.ToLower(instance.Name), strings.ToLower(filter)) {
filtered = append(filtered, instance)
}
}
return filtered
}
// redactTokens creates a copy of instances with redacted tokens
func redactTokens(instances []coolTypes.Instance) []coolTypes.Instance {
redacted := make([]coolTypes.Instance, len(instances))
for i, instance := range instances {
// Create a copy to avoid modifying original
redacted[i] = instance
if instance.Token != "" {
redacted[i].Token = "********"
}
}
return redacted
}
+51
View File
@@ -0,0 +1,51 @@
package cliinstances
import (
"errors"
"slices"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstances) newRemoveCommand() *cobra.Command {
force := false
indexToRemove := -1
cmd := &cobra.Command{
Use: "remove [name]",
Example: utils.GetCommandExample(`
%[1]s instances remove MyInstance
%[1]s instances remove localhost --force
`),
Short: "remove a instance",
Long: `
remove a instance from CLI configuration file.
`,
Aliases: []string{"delete"},
SilenceUsage: true,
Args: cobra.ExactArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
for i, instance := range c.instances {
if instance.Name == args[0] {
if !force && instance.Default {
return errors.New("instance is set as default. Please set another instance as default before removing this instance or provide the force flag")
}
indexToRemove = i
return nil
}
}
return errors.New("instance name is not found in the configuration file")
},
RunE: func(cmd *cobra.Command, args []string) error {
c.instances = slices.Delete(c.instances, indexToRemove, indexToRemove+1)
viper.Set("instances", c.instances)
return c.coolify().Save()
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Force remove instance if set as default")
return cmd
}
+26
View File
@@ -0,0 +1,26 @@
package cliinstancesset
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstancesSet) newSetDefaultCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "default [name]",
Short: "set a instance as default",
Long: `
set a instance as default from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
for i := range c.instances {
c.instances[i].Default = c.instances[i].Name == args[0]
}
viper.Set("instances", c.instances)
},
}
return cmd
}
+59
View File
@@ -0,0 +1,59 @@
package cliinstancesset
import (
"errors"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
type cliInstancesSet struct {
coolify runtime.Getter
instances []coolTypes.Instance
}
func New(c runtime.Getter) *cliInstancesSet {
return &cliInstancesSet{
coolify: c,
}
}
// Set command modifies property on a instance. Pre and Post run functions validate all children commands and save the configuration file after the child commands sets a property.
// TLDR; children commands dont need to save the configuration file or do any validation "if instances exists".
func (c *cliInstancesSet) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set [command] [args]",
Short: "set a property on a instance",
Long: `
set a property on a instance from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(1),
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if instances := viper.Get("instances"); instances != nil {
err := viper.UnmarshalKey("instances", &c.instances)
if err != nil {
return err
}
}
// Validate all set commands have instance name as the first argument and is found in the configuration file.
for _, instance := range c.instances {
if instance.Name == args[0] {
return nil
}
}
return errors.New("instance name is not found in the configuration file")
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
// Save the configuration file after setting the property.
return c.coolify().Save()
},
}
cmd.AddCommand(c.newSetDefaultCommand())
cmd.AddCommand(c.newSetTokenCommand())
return cmd
}
+29
View File
@@ -0,0 +1,29 @@
package cliinstancesset
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func (c *cliInstancesSet) newSetTokenCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "token [name] [token]",
Short: "set a instance token",
Long: `
set a instance token from CLI configuration file.
`,
SilenceUsage: true,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
for i := range c.instances {
if c.instances[i].Name == args[0] {
c.instances[i].Token = args[1]
break
}
}
viper.Set("instances", c.instances)
},
}
return cmd
}
+439
View File
@@ -0,0 +1,439 @@
package cliprivatekeys
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)
// addKeyMap defines keybindings for the add private key form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
// addKeyModel is the Bubble Tea model for the interactive add key form
type addKeyModel struct {
nameInput textinput.Model
keyInput textinput.Model
focusIndex int
done bool
err error
coolify *runtime.Coolify
keys addKeyMap
help help.Model
}
func initialAddKeyModel(coolify *runtime.Coolify) addKeyModel {
m := addKeyModel{
coolify: coolify,
keys: addKeys,
help: help.New(),
}
// Setup name input
m.nameInput = tui.NewFocusedInput("My SSH Key", " ")
m.nameInput.CharLimit = 50
m.nameInput.Width = 40
// Setup key input (multi-line)
m.keyInput = tui.NewBlurredInput("SSH private key or path to key file", " ")
m.keyInput.CharLimit = 4096
m.keyInput.Width = 60
return m
}
func (m addKeyModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok {
if key.Matches(msg, m.keys.Quit) {
return m, tea.Quit
}
if key.Matches(msg, m.keys.Help) {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
if key.Matches(msg, m.keys.Enter) {
// Submit on enter when key input is focused
if m.focusIndex == 1 {
m.done = true
return m, tea.Quit
}
// Otherwise move to next input
m.focusIndex++
if m.focusIndex > 1 {
m.focusIndex = 0
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Tab) {
// Cycle focus between inputs
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex > 1 {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = 1
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Up) {
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = 1
}
return m, m.updateFocus()
}
if key.Matches(msg, m.keys.Down) {
m.focusIndex++
if m.focusIndex > 1 {
m.focusIndex = 0
}
return m, m.updateFocus()
}
}
// Handle character input for the active input
if m.focusIndex == 0 {
var cmd tea.Cmd
m.nameInput, cmd = m.nameInput.Update(msg)
return m, cmd
} else {
var cmd tea.Cmd
m.keyInput, cmd = m.keyInput.Update(msg)
return m, cmd
}
}
func (m addKeyModel) updateFocus() tea.Cmd {
var cmds []tea.Cmd
if m.focusIndex == 0 {
m.nameInput.PromptStyle = tui.FocusedStyle
m.nameInput.TextStyle = tui.FocusedStyle
m.keyInput.PromptStyle = tui.BlurredStyle
m.keyInput.TextStyle = tui.BlurredStyle
cmds = append(cmds, m.nameInput.Focus())
m.keyInput.Blur()
} else {
m.keyInput.PromptStyle = tui.FocusedStyle
m.keyInput.TextStyle = tui.FocusedStyle
m.nameInput.PromptStyle = tui.BlurredStyle
m.nameInput.TextStyle = tui.BlurredStyle
cmds = append(cmds, m.keyInput.Focus())
m.nameInput.Blur()
}
return tea.Batch(cmds...)
}
func (m addKeyModel) View() string {
var b strings.Builder
// Title with Coolify branding
title := tui.FocusedStyle.Bold(true).Render("Add New SSH Private Key")
b.WriteString(title + "\n\n")
// Render inputs with labels
labelStyle := tui.BlurredStyle.Width(12)
b.WriteString(labelStyle.Render("Name:") + " " + m.nameInput.View() + "\n\n")
b.WriteString(labelStyle.Render("Private Key:") + " " + m.keyInput.View() + "\n\n")
// Add help view
if m.help.ShowAll {
b.WriteString("\n\n")
b.WriteString(m.help.View(m.keys))
} else {
b.WriteString("\n\n")
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
}
return b.String()
}
func generateRSAKeyPair() (privateBytes, publicBytes []byte, err error) {
// Generate RSA key pair
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate RSA key pair: %w", err)
}
// Convert private key to PEM format
privateKeyPEM := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
}
privateBytes = pem.EncodeToMemory(privateKeyPEM)
// Generate public key
publicKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
}
publicBytes = ssh.MarshalAuthorizedKey(publicKey)
return privateBytes, publicBytes, nil
}
func generateEd25519KeyPair() (privateBytes, publicBytes []byte, err error) {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate Ed25519 key pair: %w", err)
}
privateKeyPem, err := ssh.MarshalPrivateKey(privateKey, "")
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal private key: %w", err)
}
privateBytes = pem.EncodeToMemory(privateKeyPem)
// Generate public key
sshPublicKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to generate public key: %w", err)
}
publicBytes = ssh.MarshalAuthorizedKey(sshPublicKey)
return privateBytes, publicBytes, nil
}
func (c *cliPrivateKeys) generateKeyPair(name, outputDir, alorithim string, force bool) (string, error) {
var privateKey, publicKey []byte
var err error
switch alorithim {
case "rsa":
privateKey, publicKey, err = generateRSAKeyPair()
case "ed25519":
privateKey, publicKey, err = generateEd25519KeyPair()
default:
return "", fmt.Errorf("invalid alorithim: %s", alorithim)
}
if err != nil {
return "", err
}
if outputDir != "" {
if err := os.MkdirAll(outputDir, 0o700); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
// Write private key file
privateKeyPath := filepath.Join(outputDir, name)
if !force {
if _, err := os.Stat(privateKeyPath); err == nil {
return "", fmt.Errorf("private key file already exists: %s", privateKeyPath)
}
}
if err := os.WriteFile(privateKeyPath, privateKey, 0o600); err != nil {
return "", fmt.Errorf("failed to write private key file: %w", err)
}
// Write public key file
publicKeyPath := privateKeyPath + ".pub"
if err := os.WriteFile(publicKeyPath, publicKey, 0o644); err != nil {
return "", fmt.Errorf("failed to write public key file: %w", err)
}
fmt.Printf("Generated SSH key pair:\n")
fmt.Printf(" Private key: %s\n", privateKeyPath)
fmt.Printf(" Public key: %s\n", publicKeyPath)
}
return string(privateKey), nil
}
func (c *cliPrivateKeys) newAddCommand() *cobra.Command {
var generateKeyPair bool
var outPutDirectory string
var algorithm string
var force bool
cmd := &cobra.Command{
Use: "add [name] [private_key_or_file]",
Short: "Add a new private key",
Long: `Add a new SSH private key to your Coolify instance.
The key can be provided directly as a string or as a path to a file.
Use --generate to create a new SSH key pair.
If no arguments are provided, an interactive form will be used.`,
Example: utils.GetCommandExample(`
%[1]s private-keys add "My Key" /path/to/id_rsa
%[1]s private-keys add "My Key" "-----BEGIN RSA PRIVATE KEY-----..."
%[1]s private-keys add "My Key" --generate # Generate key pair
%[1]s private-keys add # Interactive mode
`),
SilenceUsage: true,
Args: func(cmd *cobra.Command, args []string) error {
if generateKeyPair {
if len(args) != 1 {
return fmt.Errorf("when using --generate, provide only the key name")
}
return nil
}
return cobra.RangeArgs(0, 2)(cmd, args)
},
RunE: func(cmd *cobra.Command, args []string) error {
// Handle key generation
if generateKeyPair {
name := args[0]
privateKey, err := c.generateKeyPair(name, outPutDirectory, algorithm, force)
if err != nil {
return err
}
return c.addPrivateKey(cmd.Context(), name, privateKey)
}
// Interactive mode when no arguments are provided
if len(args) == 0 {
model := initialAddKeyModel(c.coolify())
p := tea.NewProgram(model)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("error running interactive mode: %w", err)
}
// Process the final model after user submission
finalState := finalModel.(addKeyModel)
if !finalState.done {
return fmt.Errorf("operation canceled")
}
name := finalState.nameInput.Value()
privateKeyInput := finalState.keyInput.Value()
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
}
// CLI mode with arguments
if len(args) != 2 {
return fmt.Errorf("requires both NAME and PRIVATE_KEY_OR_FILE arguments")
}
name := args[0]
privateKeyInput := args[1]
return c.addPrivateKey(cmd.Context(), name, privateKeyInput)
},
}
flags := cmd.Flags()
flags.SortFlags = false
flags.BoolVarP(&generateKeyPair, "generate", "g", false, "generate a new key pair")
flags.StringVarP(&algorithm, "algorithm", "a", "rsa", "algorithm to use for the key pair")
flags.StringVarP(&outPutDirectory, "output", "o", "", "optional output directory for the key pair")
flags.BoolVarP(&force, "force", "f", false, "force the generation of the key pair if the name exists on the file system within the output directory")
return cmd
}
// addPrivateKey adds a private key to the Coolify instance
func (c *cliPrivateKeys) addPrivateKey(ctx context.Context, name, privateKeyInput string) error {
// Check if input is a file path
var privateKey string
if _, err := os.Stat(privateKeyInput); err == nil {
keyBytes, err := os.ReadFile(privateKeyInput)
if err != nil {
return fmt.Errorf("error reading private key file: %w", err)
}
privateKey = string(keyBytes)
} else {
privateKey = privateKeyInput
}
req, err := c.coolify().Client.CreatePrivateKey(ctx, openapi.CreatePrivateKeyJSONRequestBody{
Name: &name,
PrivateKey: privateKey,
})
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseCreatePrivateKeyResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusCreated {
return fmt.Errorf("failed to add private key: %s", string(parsedResponse.Body))
}
fmt.Printf("Private key '%s' added successfully as UUID: %s\n", name, *parsedResponse.JSON201.Uuid)
return nil
}
+266
View File
@@ -0,0 +1,266 @@
package cliprivatekeys
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func buildView(item openapi.PrivateKey, sensitive bool) string {
var s strings.Builder
addSection := func(title string, value interface{}) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
if value != nil {
switch v := value.(type) {
case *string:
if v != nil {
s.WriteString(*v + "\n\n")
}
case *bool:
if v != nil {
s.WriteString(fmt.Sprintf("%v\n\n", *v))
}
case *int:
if v != nil {
s.WriteString(fmt.Sprintf("%d\n\n", *v))
}
}
} else {
s.WriteString("N/A\n\n")
}
}
addSection("UUID", item.Uuid)
addSection("Name", item.Name)
addSection("Description", item.Description)
addSection("Fingerprint", item.Fingerprint)
if sensitive {
addSection("Private Key", item.PrivateKey)
addSection("Public Key", item.PublicKey)
} else {
addSection("Private Key", &coolTypes.Redacted)
addSection("Public Key", &coolTypes.Redacted)
}
addSection("Git Related", item.IsGitRelated)
addSection("Team ID", item.TeamId)
addSection("Created At", item.CreatedAt)
addSection("Updated At", item.UpdatedAt)
return s.String()
}
type keyMap struct {
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
Quit key.Binding
ShowSensitive key.Binding
}
func defaultKeyMap() keyMap {
return keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "move down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("pgdown", "page down"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
ShowSensitive: key.NewBinding(
key.WithKeys("ctrl+s"),
key.WithHelp("ctrl+s", "show sensitive"),
),
}
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down},
{k.PageUp, k.PageDown},
{k.Quit},
{k.ShowSensitive},
}
}
type privateKeyModel struct {
viewport viewport.Model
keymap keyMap
help help.Model
ready bool
privateKey openapi.PrivateKey
sensitive bool
quitting bool
err error
}
func newPrivateKeyModel(privateKey openapi.PrivateKey, sensitive bool) privateKeyModel {
return privateKeyModel{
keymap: defaultKeyMap(),
help: help.New(),
privateKey: privateKey,
sensitive: sensitive,
}
}
func (m privateKeyModel) Init() tea.Cmd {
return nil
}
func (m privateKeyModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keymap.Quit):
m.quitting = true
return m, tea.Quit
case key.Matches(msg, m.keymap.Up):
m.viewport.LineUp(1)
case key.Matches(msg, m.keymap.Down):
m.viewport.LineDown(1)
case key.Matches(msg, m.keymap.PageUp):
m.viewport.HalfViewUp()
case key.Matches(msg, m.keymap.PageDown):
m.viewport.HalfViewDown()
case key.Matches(msg, m.keymap.ShowSensitive):
m.sensitive = !m.sensitive
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
}
case tea.WindowSizeMsg:
if !m.ready {
m.viewport = viewport.New(msg.Width, msg.Height-4)
m.viewport.Style = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Padding(0, 2)
m.viewport.SetContent(buildView(m.privateKey, m.sensitive))
m.help.Width = msg.Width
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - 4
m.help.Width = msg.Width
}
}
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m privateKeyModel) View() string {
if !m.ready {
return "Initializing..."
}
if m.err != nil {
return fmt.Sprintf("Error: %v\nPress esc to quit", m.err)
}
var s strings.Builder
s.WriteString(m.viewport.View())
s.WriteString("\n\n")
s.WriteString(m.help.View(m.keymap))
return s.String()
}
func (c *cliPrivateKeys) newGetCommand() *cobra.Command {
var showSensitive bool
cmd := &cobra.Command{
Use: "get [uuid]",
Short: "Get private key details",
Long: `Get the details of a specific private key by its UUID.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
response, err := c.coolify().Client.GetPrivateKeyByUuid(cmd.Context(), uuid)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseGetPrivateKeyByUuidResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
return fmt.Errorf("failed to fetch private key: %s", string(parsedResponse.Body))
}
key := *parsedResponse.JSON200
format, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format: %w", err)
}
if format == "json" {
// Redact sensitive data if --show-sensitive is not set
if !showSensitive {
// Create a copy with redacted sensitive fields
redactedKey := key
redactedKey.PrivateKey = &coolTypes.Redacted
redactedKey.PublicKey = &coolTypes.Redacted
key = redactedKey
}
// For JSON output, directly encode to stdout
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
return encoder.Encode(key)
}
// Initialize and run Bubble Tea program
m := newPrivateKeyModel(key, showSensitive)
p := tea.NewProgram(m)
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running program: %w", err)
}
return nil
},
}
// Add flags
flags := cmd.Flags()
flags.BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like key contents")
return cmd
}
+203
View File
@@ -0,0 +1,203 @@
package cliprivatekeys
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/coolTypes"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type filterableListModel struct {
FilterableTable *tui.FilterableTable
}
func newFilterableListModel(keys []openapi.PrivateKey, filter string) *filterableListModel {
columns := []table.Column{
{Title: "UUID", Width: 30},
{Title: "Name", Width: 30},
{Title: "Created At", Width: 30},
}
return &filterableListModel{
FilterableTable: tui.NewTableFilter(wrapKeys(keys), columns, buildRow).
WithInitialFilter(filter).
WithDetailView(buildDetailView).
WithDetailHeader("Private Key Details"),
}
}
func wrapKeys(keys []openapi.PrivateKey) []tui.FilterableItem {
items := make([]tui.FilterableItem, len(keys))
for i, key := range keys {
items[i] = &key
}
return items
}
func buildRow(item tui.FilterableItem) table.Row {
key := item.(*openapi.PrivateKey)
return table.Row{
*key.Uuid,
*key.Name,
*key.CreatedAt,
}
}
func buildDetailView(item tui.FilterableItem, sensitive bool) string {
key := item.(*openapi.PrivateKey)
var s strings.Builder
addSection := func(title string, value interface{}) {
s.WriteString(tui.FocusedStyle.Bold(true).Render(title + ": "))
if value != nil {
switch v := value.(type) {
case *string:
if v != nil {
s.WriteString(*v + "\n\n")
}
case *bool:
if v != nil {
s.WriteString(fmt.Sprintf("%v\n\n", *v))
}
case *int:
if v != nil {
s.WriteString(fmt.Sprintf("%d\n\n", *v))
}
}
} else {
s.WriteString("N/A\n\n")
}
}
addSection("UUID", key.Uuid)
addSection("Name", key.Name)
addSection("Description", key.Description)
addSection("Fingerprint", key.Fingerprint)
if sensitive {
addSection("Private Key", key.PrivateKey)
addSection("Public Key", key.PublicKey)
} else {
addSection("Private Key", &coolTypes.Redacted)
addSection("Public Key", &coolTypes.Redacted)
}
addSection("Git Related", key.IsGitRelated)
addSection("Team ID", key.TeamId)
addSection("Created At", key.CreatedAt)
addSection("Updated At", key.UpdatedAt)
return s.String()
}
func (m *filterableListModel) Init() tea.Cmd {
return nil
}
func (m *filterableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.FilterableTable.Update(msg)
}
func (m *filterableListModel) View() string {
return m.FilterableTable.View()
}
func (c *cliPrivateKeys) handleDelete(item tui.FilterableItem) error {
key := item.(*openapi.PrivateKey)
deleteReq, err := c.coolify().Client.DeletePrivateKeyByUuid(context.Background(), *key.Uuid)
if err != nil {
return fmt.Errorf("failed to create delete request: %w", err)
}
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(deleteReq)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
switch parsedResponse.StatusCode() {
case http.StatusUnprocessableEntity:
return fmt.Errorf("failed to delete private key: %s", *parsedResponse.JSON422.Message)
case http.StatusOK:
return nil
default:
return fmt.Errorf("failed to delete private key: %s", string(parsedResponse.Body))
}
}
func (c *cliPrivateKeys) newListCommand() *cobra.Command {
var filter string
var showSensitive bool
cmd := &cobra.Command{
Use: "list [filter]",
Short: "List all private keys",
Long: `List all SSH private keys registered in your Coolify instance.`,
Example: utils.GetCommandExample(`
%[1]s private-keys list --format json
%[1]s private-keys list "My Key"
%[1]s private-keys list --show-sensitive
%[1]s private-keys list # Interactive mode
`),
SilenceUsage: true,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
filter = args[0]
}
response, err := c.coolify().Client.ListPrivateKeys(cmd.Context())
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseListPrivateKeysResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
return fmt.Errorf("failed to fetch private keys: %s", string(parsedResponse.Body))
}
keys := *parsedResponse.JSON200
format, _ := cmd.Flags().GetString("format")
if format == "json" {
// For JSON output, redact sensitive data if --show-sensitive is not set
if !showSensitive {
// Create a copy with redacted sensitive fields
redactedKeys := make([]openapi.PrivateKey, len(*parsedResponse.JSON200))
for i, key := range *parsedResponse.JSON200 {
redactedKeys[i] = key
redactedKeys[i].PrivateKey = &coolTypes.Redacted
redactedKeys[i].PublicKey = &coolTypes.Redacted
}
keys = redactedKeys
}
// For JSON output, directly encode to stdout
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetIndent("", " ")
return encoder.Encode(keys)
}
model := newFilterableListModel(keys, filter)
model.FilterableTable.WithDeleteHandler(c.handleDelete)
p := tea.NewProgram(model)
_, err = p.Run()
return err
},
}
cmd.Flags().BoolVarP(&showSensitive, "show-sensitive", "s", false, "Show sensitive information like public keys")
return cmd
}
+32
View File
@@ -0,0 +1,32 @@
package cliprivatekeys
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliPrivateKeys struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliPrivateKeys {
return &cliPrivateKeys{
coolify: c,
}
}
func (c *cliPrivateKeys) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "private-keys",
Short: "Manage SSH private keys",
Long: `Manage SSH private keys for your Coolify instance.`,
}
// Add subcommands
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(c.newGetCommand())
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
return cmd
}
+70
View File
@@ -0,0 +1,70 @@
package cliprivatekeys
import (
"fmt"
"net/http"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func (c *cliPrivateKeys) newRemoveCommand() *cobra.Command {
var forceRemove bool
cmd := &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a private key",
Long: `Remove an private key from your Coolify instance.`,
SilenceUsage: true,
Aliases: []string{"delete", "rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
if !forceRemove {
fmt.Printf("Are you sure you want to remove the private key with UUID '%s'? [y/N] ", uuid)
var confirm string
_, err := fmt.Scanln(&confirm)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if confirm != "y" && confirm != "Y" {
fmt.Println("Operation canceled")
return nil
}
}
req, err := c.coolify().Client.DeletePrivateKeyByUuid(cmd.Context(), uuid)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseDeletePrivateKeyByUuidResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
errorMessage := "failed to remove private key"
switch parsedResponse.StatusCode() {
case http.StatusBadRequest:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON400.Message)
case http.StatusUnprocessableEntity:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, *parsedResponse.JSON422.Message)
default:
errorMessage = fmt.Sprintf("%s: %s", errorMessage, string(parsedResponse.Body))
}
return fmt.Errorf("%s", errorMessage)
}
fmt.Println(tui.SuccessStyle.Render("Private key removed successfully"))
return nil
},
}
// Add flags
flags := cmd.Flags()
flags.BoolVarP(&forceRemove, "force", "f", false, "Attempt to remove without confirmation prompt")
return cmd
}
+319
View File
@@ -0,0 +1,319 @@
package cliservers
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
// addKeyMap defines keybindings for the add server form
type addKeyMap struct {
Up key.Binding
Down key.Binding
Tab key.Binding
Enter key.Binding
Help key.Binding
Quit key.Binding
}
// ShortHelp returns keybindings to be shown in the mini help view
func (k addKeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
// FullHelp returns keybindings for the expanded help view
func (k addKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Tab}, // first column
{k.Enter, k.Help}, // second column
{k.Quit}, // third column
}
}
var addKeys = addKeyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "move down"),
),
Tab: key.NewBinding(
key.WithKeys("tab", "shift+tab"),
key.WithHelp("tab", "next field"),
),
Enter: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "submit/select"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("esc", "ctrl+c"),
key.WithHelp("esc", "quit"),
),
}
type addModel struct {
inputs []textinput.Model
focusIndex int
err error
done bool
keys addKeyMap
help help.Model
}
func (c *cliServers) newAddCommand() *cobra.Command {
var validate bool
cmd := &cobra.Command{
Use: "add [name] [ip] [private_key_uuid]",
Short: "Add a new server",
Long: `
Add a new server to your Coolify instance.
If no arguments are provided, an interactive form will be shown.`,
SilenceUsage: true,
Example: utils.GetCommandExample(`
%[1]s servers add "My Server" 192.168.1.100 abcd1234-uuid
%[1]s servers add "Production" 10.0.0.1 efgh5678-uuid --validate
%[1]s servers add # Interactive mode`),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return c.runInteractiveAdd(validate)
}
if len(args) != 3 {
return fmt.Errorf("requires exactly 3 arguments (name, ip, private_key_uuid) or no arguments for interactive mode")
}
return c.addServer(args[0], args[1], args[2], 22, "root", validate)
},
}
cmd.Flags().BoolVar(&validate, "validate", false, "Validate the server after adding")
return cmd
}
func (c *cliServers) runInteractiveAdd(validate bool) error {
p := tea.NewProgram(initialAddModel())
m, err := p.Run()
if err != nil {
return fmt.Errorf("error running form: %w", err)
}
finalModel := m.(addModel)
if !finalModel.done {
return fmt.Errorf("operation cancelled")
}
// Get values from the form
name := strings.TrimSpace(finalModel.inputs[0].Value())
ip := strings.TrimSpace(finalModel.inputs[1].Value())
port := strings.TrimSpace(finalModel.inputs[2].Value())
user := strings.TrimSpace(finalModel.inputs[3].Value())
privateKeyUUID := strings.TrimSpace(finalModel.inputs[4].Value())
// Convert port to int with default 22
portNum := 22
if port != "" {
if _, err := fmt.Sscanf(port, "%d", &portNum); err != nil {
return fmt.Errorf("invalid port number: %s", port)
}
}
// Use default user if not specified
if user == "" {
user = "root"
}
return c.addServer(name, ip, privateKeyUUID, portNum, user, validate)
}
func initialAddModel() addModel {
inputs := make([]textinput.Model, 5)
// Initialize text inputs
labels := []string{"Name", "IP Address", "Port (default: 22)", "User (default: root)", "Private Key UUID"}
for i := range inputs {
input := tui.NewBlurredInput(labels[i], "")
inputs[i] = input
}
inputs[0].Focus()
return addModel{
inputs: inputs,
err: nil,
keys: addKeys,
help: help.New(),
}
}
func (m addModel) Init() tea.Cmd {
return textinput.Blink
}
func (m addModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if msg, ok := msg.(tea.KeyMsg); ok {
if key.Matches(msg, m.keys.Quit) {
m.done = false
return m, tea.Quit
}
if key.Matches(msg, m.keys.Help) {
m.help.ShowAll = !m.help.ShowAll
}
if key.Matches(msg, m.keys.Enter) {
// Submit on enter when last input is focused
if m.focusIndex == len(m.inputs)-1 {
m.done = true
return m, tea.Quit
}
// Otherwise move to next input
m.focusIndex++
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Tab) {
// Cycle focus between inputs
if msg.String() == "shift+tab" {
m.focusIndex--
} else {
m.focusIndex++
}
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
} else if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Up) {
m.focusIndex--
if m.focusIndex < 0 {
m.focusIndex = len(m.inputs) - 1
}
m.updateFocus()
}
if key.Matches(msg, m.keys.Down) {
m.focusIndex++
if m.focusIndex >= len(m.inputs) {
m.focusIndex = 0
}
m.updateFocus()
}
}
// Handle character input
cmd := m.updateInputs(msg)
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *addModel) updateFocus() {
for i := 0; i < len(m.inputs); i++ {
if i == m.focusIndex {
m.inputs[i].Focus()
} else {
m.inputs[i].Blur()
}
}
}
func (m *addModel) updateInputs(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
m.inputs[m.focusIndex], cmd = m.inputs[m.focusIndex].Update(msg)
return cmd
}
func (m addModel) View() string {
var b strings.Builder
b.WriteString("Please enter server details:\n\n")
for i, input := range m.inputs {
b.WriteString(input.View())
if i < len(m.inputs)-1 {
b.WriteString("\n")
}
}
button := "\n\n"
if m.focusIndex == len(m.inputs)-1 {
button += lipgloss.NewStyle().
Foreground(lipgloss.Color("99")).
Render("[ Submit ]")
} else {
button += "[ Submit ]"
}
b.WriteString(button)
// Add help view
if m.help.ShowAll {
b.WriteString("\n\n")
b.WriteString(m.help.View(m.keys))
} else {
b.WriteString("\n\n")
b.WriteString(m.help.ShortHelpView(m.keys.ShortHelp()))
}
return b.String()
}
func (c *cliServers) addServer(name, ip, privateKeyUUID string, port int, user string, validate bool) error {
req, err := c.coolify().Client.CreateServer(context.Background(), openapi.CreateServerJSONRequestBody{
Name: &name,
Ip: &ip,
Port: &port,
User: &user,
PrivateKeyUuid: &privateKeyUUID,
InstantValidate: &validate,
})
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseCreateServerResponse(req)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusCreated {
return fmt.Errorf("failed to add server: %s", *parsedResponse.JSON400.Message)
}
if validate {
fmt.Printf("Server added successfully with uuid %s\n", *parsedResponse.JSON201.Uuid)
} else {
fmt.Printf("Server added successfully with uuid %s. Server is not validated. Use 'servers validate %s' to validate the server.\n", *parsedResponse.JSON201.Uuid, *parsedResponse.JSON201.Uuid)
}
return nil
}
+163
View File
@@ -0,0 +1,163 @@
package cliservers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type getModel struct {
server *openapi.Server
sensitive bool
withResources bool
err error
}
func (c *cliServers) newGetCommand() *cobra.Command {
var withResources bool
cmd := &cobra.Command{
Use: "get [uuid]",
Short: "Get server details",
Long: `
Get detailed information about a specific server.
Optionally show its resources and sensitive information.`,
Example: utils.GetCommandExample(`
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --resources
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --sensitive
%[1]s servers get 123e4567-e89b-12d3-a456-426614174000 --format json`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
// Fetch server details
serverData, err := c.fetchServer(cmd.Context(), uuid, withResources)
if err != nil {
return fmt.Errorf("failed to fetch server details: %w", err)
}
outFormat, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get output format: %w", err)
}
// Handle JSON output format
if outFormat == "json" {
return json.NewEncoder(os.Stdout).Encode(serverData)
}
// Create and run Bubble Tea program for interactive display
p := tea.NewProgram(initialGetModel(serverData))
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running detail view: %w", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVar(&withResources, "resources", false, "Show server resources")
return cmd
}
func initialGetModel(server *openapi.Server) getModel {
return getModel{
server: server,
}
}
// Implement Bubble Tea Model interface
func (m getModel) Init() tea.Cmd { return nil }
func (m getModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == "ctrl+c" || msg.String() == "esc" {
return m, tea.Quit
}
}
return m, nil
}
func (m getModel) View() string {
var s strings.Builder
// Create styles
titleStyle := tui.FocusedStyle.
Bold(true).
MarginBottom(1)
labelStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("60"))
valueStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("99"))
// Server details section
s.WriteString(titleStyle.Render("Server Details"))
s.WriteString("\n")
// Helper function to add a field
addField := func(label, value string) {
s.WriteString(fmt.Sprintf("%s: %s\n",
labelStyle.Render(label),
valueStyle.Render(value)))
}
addField("UUID", *m.server.Uuid)
addField("Name", *m.server.Name)
addField("IP Address", *m.server.Ip)
addField("User", *m.server.User)
addField("Port", fmt.Sprintf("%d", *m.server.Port))
status := "Offline"
if *m.server.Settings.IsReachable && *m.server.Settings.IsUsable {
status = "Online"
}
addField("Status", status)
return "\n" + s.String()
}
func (c *cliServers) fetchServer(ctx context.Context, uuid string, withResources bool) (*openapi.Server, error) {
req, err := c.coolify().Client.GetServerByUuid(ctx, uuid, func(ctx context.Context, req *http.Request) error {
if withResources {
req.URL.RawQuery = url.Values{"resources": {"true"}}.Encode()
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseGetServerByUuidResponse(req)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
switch parsedResponse.StatusCode() {
case http.StatusNotFound:
return nil, fmt.Errorf("failed to get server: %s", *parsedResponse.JSON404.Message)
default:
return nil, fmt.Errorf("failed to get server: %s", string(parsedResponse.Body))
}
}
return parsedResponse.JSON200, nil
}
+216
View File
@@ -0,0 +1,216 @@
package cliservers
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type listModel struct {
filterableTable *tui.FilterableTable
servers *[]openapi.Server
sensitive bool
filter string
err error
}
func (c *cliServers) newListCommand() *cobra.Command {
var showSensitive bool
var initialFilter string
cmd := &cobra.Command{
Use: "list [filter]",
Short: "List all servers",
Long: `
List all servers registered in your Coolify instance.
Use --sensitive to show sensitive information like IP addresses.`,
Example: utils.GetCommandExample(`
%[1]s servers list
%[1]s servers list "my-server"
%[1]s servers list --format json
%[1]s servers list --sensitive`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
initialFilter = args[0]
}
// Fetch servers from API
data, err := c.fetchServers(cmd.Context())
if err != nil {
return fmt.Errorf("failed to fetch servers: %w", err)
}
outputFormat, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get output format: %w", err)
}
// Handle JSON output format
if outputFormat == "json" {
return json.NewEncoder(os.Stdout).Encode(data)
}
// Create and run Bubble Tea program for interactive display
p := tea.NewProgram(initialListModel(data, showSensitive, initialFilter))
if _, err := p.Run(); err != nil {
return fmt.Errorf("error running list view: %w", err)
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&showSensitive, "sensitive", "s", false, "Show sensitive information")
return cmd
}
func initialListModel(servers *[]openapi.Server, sensitive bool, initialFilter string) listModel {
columns := []table.Column{
{Title: "UUID", Width: 36},
{Title: "Name", Width: 30},
{Title: "IP Address", Width: 15},
}
// Convert servers to FilterableItems
items := make([]tui.FilterableItem, len(*servers))
for i, s := range *servers {
items[i] = &s
}
// Create row builder function
rowBuilder := func(item tui.FilterableItem) table.Row {
s := item.(*openapi.Server)
return table.Row{
*s.Uuid,
*s.Name,
*s.Ip,
}
}
detailBuilder := func(item tui.FilterableItem, sensitive bool) string {
s := item.(*openapi.Server)
var builder strings.Builder
addSection := func(title, value interface{}) {
builder.WriteString(tui.FocusedStyle.Bold(true).Render(fmt.Sprintf("%s: ", title)))
switch v := value.(type) {
case *string:
builder.WriteString(*v)
case *int:
builder.WriteString(fmt.Sprintf("%d", *v))
case *openapi.ServerProxyType:
if v != nil {
builder.WriteString(string(*v))
} else {
builder.WriteString("N/A")
}
case string:
builder.WriteString(v)
case *bool:
if v != nil {
builder.WriteString(fmt.Sprintf("%t", *v))
} else {
builder.WriteString("N/A")
}
}
builder.WriteString("\n\n")
}
addSection("UUID", s.Uuid)
addSection("Name", s.Name)
addSection("IP Address", s.Ip)
addSection("User", s.User)
addSection("Port", s.Port)
addSection("Proxy Type", s.ProxyType)
addSection("Settings", "")
addSection(" Created At", s.Settings.CreatedAt)
addSection(" Updated At", s.Settings.UpdatedAt)
addSection(" Server ID", s.Settings.ServerId)
addSection(" Concurrent Builds", s.Settings.ConcurrentBuilds)
addSection(" Dynamic Timeout", s.Settings.DynamicTimeout)
addSection(" Docker", "")
addSection(" Delete Unused Networks", s.Settings.DeleteUnusedNetworks)
addSection(" Delete Unused Volumes", s.Settings.DeleteUnusedVolumes)
addSection(" Cleanup Frequency", s.Settings.DockerCleanupFrequency)
addSection(" Cleanup Threshold", s.Settings.DockerCleanupThreshold)
addSection(" Force Disabled", s.Settings.ForceDisabled)
addSection(" Force Server Cleanup", s.Settings.ForceServerCleanup)
addSection(" Is Build Server", s.Settings.IsBuildServer)
addSection(" Is Cloudflare Tunnel", s.Settings.IsCloudflareTunnel)
addSection(" Is Jump Server", s.Settings.IsJumpServer)
if s.Settings.IsLogdrainAxiomEnabled != nil && *s.Settings.IsLogdrainAxiomEnabled {
addSection(" Axiom", "")
addSection(" API Key", s.Settings.LogdrainAxiomApiKey)
addSection(" Dataset Name", s.Settings.LogdrainAxiomDatasetName)
}
if s.Settings.IsLogdrainCustomEnabled != nil && *s.Settings.IsLogdrainCustomEnabled {
addSection(" Custom Drain", "")
addSection(" Config", s.Settings.LogdrainCustomConfig)
addSection(" Config Parser", s.Settings.LogdrainCustomConfigParser)
}
if s.Settings.IsLogdrainHighlightEnabled != nil && *s.Settings.IsLogdrainHighlightEnabled {
addSection(" Highlight", "")
addSection(" Project ID", s.Settings.LogdrainHighlightProjectId)
}
if s.Settings.IsLogdrainNewrelicEnabled != nil && *s.Settings.IsLogdrainNewrelicEnabled {
addSection(" Newrelic", "")
addSection(" Base URI", s.Settings.LogdrainNewrelicBaseUri)
addSection(" License Key", s.Settings.LogdrainNewrelicLicenseKey)
}
addSection(" Metrics", "")
addSection(" History Days", s.Settings.SentinelMetricsHistoryDays)
addSection(" Refresh Rate", s.Settings.SentinelMetricsRefreshRateSeconds)
addSection(" Token", s.Settings.SentinelToken)
return builder.String()
}
ft := tui.NewTableFilter(items, columns, rowBuilder).
WithInitialFilter(initialFilter).
WithDetailView(detailBuilder)
return listModel{
filterableTable: ft,
servers: servers,
sensitive: sensitive,
filter: initialFilter,
}
}
// Implement Bubble Tea Model interface
func (m listModel) Init() tea.Cmd { return nil }
func (m listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.filterableTable.Update(msg)
}
func (m listModel) View() string {
return m.filterableTable.View()
}
func (c *cliServers) fetchServers(ctx context.Context) (*[]openapi.Server, error) {
req, err := c.coolify().Client.ListServers(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
parsedResponse, err := openapi.ParseListServersResponse(req)
if err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return parsedResponse.JSON200, nil
}
+66
View File
@@ -0,0 +1,66 @@
package cliservers
import (
"fmt"
"net/http"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
func (c *cliServers) newRemoveCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "remove [uuid]",
Short: "Remove a server",
Long: `
Remove a server from your Coolify instance.
This action cannot be undone.`,
Example: utils.GetCommandExample(`
%[1]s servers remove [uuid]
%[1]s servers remove [uuid] --force`),
Aliases: []string{"delete", "rm"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
toRemove := args[0]
if !force {
fmt.Printf("Are you sure you want to remove the server with UUID '%s'? [y/N] ", toRemove)
var confirm string
_, err := fmt.Scanln(&confirm)
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
if confirm != "y" && confirm != "Y" {
fmt.Println("Operation cancelled")
return nil
}
}
response, err := c.coolify().Client.DeleteServerByUuid(cmd.Context(), toRemove)
if err != nil {
return fmt.Errorf("failed to remove server: %w", err)
}
parsedResponse, err := openapi.ParseDeleteServerByUuidResponse(response)
if err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
if parsedResponse.StatusCode() != http.StatusOK {
switch parsedResponse.StatusCode() {
case http.StatusNotFound:
return fmt.Errorf("failed to remove server: %s", *parsedResponse.JSON404.Message)
default:
return fmt.Errorf("failed to remove server: %s", string(parsedResponse.Body))
}
}
fmt.Println(tui.SuccessStyle.Render(*parsedResponse.JSON200.Message))
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
return cmd
}
+36
View File
@@ -0,0 +1,36 @@
package cliservers
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliServers struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliServers {
return &cliServers{
coolify: c,
}
}
// NewCommand creates and returns the servers command
func (c *cliServers) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "servers",
Short: "Manage Coolify servers",
Long: `
Manage servers in your Coolify instance.
This command allows you to list, add, remove, and manage servers.`,
}
// Add subcommands
cmd.AddCommand(c.newListCommand())
cmd.AddCommand(c.newGetCommand())
cmd.AddCommand(c.newAddCommand())
cmd.AddCommand(c.newRemoveCommand())
cmd.AddCommand(c.newValidateCommand())
return cmd
}
+151
View File
@@ -0,0 +1,151 @@
package cliservers
import (
"context"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/cmd/utils"
"github.com/coollabsio/cli-coolify/pkg/gen/openapi"
"github.com/coollabsio/cli-coolify/pkg/tui"
"github.com/spf13/cobra"
)
type validateModel struct {
spinner spinner.Model
uuid string
done bool
err error
response string
coolify runtime.Getter
ctx context.Context
}
type validateSuccessMsg struct {
message string
}
type validateErrorMsg struct {
err error
}
func (c *cliServers) newValidateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "validate [uuid]",
Short: "Validate server connection",
Long: `
Validate the connection to a server in your Coolify instance.
This will check if the server is reachable and usable.`,
Example: utils.GetCommandExample(`
%[1]s servers validate 123e4567-e89b-12d3-a456-426614174000`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uuid := args[0]
p := tea.NewProgram(initialValidateModel(uuid, c.coolify, cmd.Context()))
model, err := p.Run()
if err != nil {
return fmt.Errorf("error running validation: %w", err)
}
finalModel := model.(validateModel)
if finalModel.err != nil {
return finalModel.err
}
return nil
},
}
return cmd
}
func initialValidateModel(uuid string, coolify runtime.Getter, ctx context.Context) validateModel {
s := spinner.New()
s.Spinner = spinner.Points
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("99"))
return validateModel{
spinner: s,
uuid: uuid,
coolify: coolify,
ctx: ctx,
}
}
func (m validateModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Tick,
m.validateServer,
)
}
func (m validateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case validateSuccessMsg:
m.done = true
m.response = msg.message
return m, tea.Quit
case validateErrorMsg:
m.done = true
m.err = msg.err
return m, tea.Quit
}
return m, nil
}
func (m validateModel) View() string {
if m.done {
if m.err != nil {
return tui.ErrorStyle.Render(fmt.Sprintf("Error: %v\n", m.err))
}
return tui.SuccessStyle.Render(m.response + "\n")
}
return fmt.Sprintf("%s Validating server...\n", m.spinner.View())
}
func (m validateModel) validateServer() tea.Msg {
// Simulate network delay for better UX
time.Sleep(500 * time.Millisecond)
server, err := m.coolify().Client.ValidateServerByUuid(m.ctx, m.uuid)
if err != nil {
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %w", err)}
}
parsedResponse, err := openapi.ParseValidateServerByUuidResponse(server)
if err != nil {
return validateErrorMsg{err: fmt.Errorf("failed to parse server response: %w", err)}
}
if parsedResponse.StatusCode() != http.StatusCreated {
switch parsedResponse.StatusCode() {
case http.StatusBadRequest:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON400.Message)}
case http.StatusNotFound:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", *parsedResponse.JSON404.Message)}
default:
return validateErrorMsg{err: fmt.Errorf("failed to validate server: %s", string(parsedResponse.Body))}
}
}
return validateSuccessMsg{message: string(*parsedResponse.JSON201.Message)}
}
+84
View File
@@ -0,0 +1,84 @@
package cliupdate
import (
"fmt"
"runtime"
"strings"
coolifyRuntime "github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/coollabsio/cli-coolify/pkg/updater"
"github.com/spf13/cobra"
)
type cliUpdate struct {
coolify coolifyRuntime.Getter
}
func New(c coolifyRuntime.Getter) *cliUpdate {
return &cliUpdate{
coolify: c,
}
}
func (c *cliUpdate) NewCommand() *cobra.Command {
var preRelease bool
cmd := &cobra.Command{
Use: "update",
Short: "Update Coolify CLI",
Long: `
Update the Coolify CLI to the latest version from GitHub releases.
By default, the command will update to the latest stable version.
Use the --pre-release flag to update to the latest pre-release version.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// we should check if the current version is a pre-release
currentVersion := c.coolify().Version
isPreRelease := strings.Contains(currentVersion, "-")
// Create our custom updater
update := updater.New("coollabsio", "cli-coolify", c.coolify().Version)
// Check for updates
c.coolify().Logger.Infof("Checking for updates...")
// Check if an update is available without performing the update
release, hasUpdate, err := update.Check(cmd.Context(), preRelease)
if err != nil {
return fmt.Errorf("error checking for updates: %v", err)
}
if isPreRelease && !preRelease && !hasUpdate {
c.coolify().Logger.Warnf("You are on a pre-release version of the CLI. Use the --pre-release flag to update to the latest pre-release version.")
return nil
}
if !hasUpdate {
c.coolify().Logger.Infof("You are already on the latest version: %s\n", c.coolify().GetFormattedVersion())
return nil
}
c.coolify().Logger.Infof("Found new version: v%s (current: %s)\n", release.Version, c.coolify().GetFormattedVersion())
// Format OS/Arch for display
platform := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)
c.coolify().Logger.Infof("Downloading update for %s...", platform)
// Perform the update
newVersion, err := update.To(cmd.Context(), release)
if err != nil {
return fmt.Errorf("update failed: %v", err)
}
c.coolify().Logger.Infof("Successfully updated to version v%s\n", newVersion)
return nil
},
}
flags := cmd.Flags()
flags.BoolVar(&preRelease, "pre-release", false, "Update to pre-release version")
return cmd
}
+33
View File
@@ -0,0 +1,33 @@
package cliversion
import (
"github.com/coollabsio/cli-coolify/cmd/runtime"
"github.com/spf13/cobra"
)
type cliVersion struct {
coolify runtime.Getter
}
func New(c runtime.Getter) *cliVersion {
return &cliVersion{
coolify: c,
}
}
func (c *cliVersion) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "version ",
Short: "CLI version",
Long: `
Print the version of the CLI.
`,
SilenceUsage: true,
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
cmd.Println(c.coolify().GetFormattedVersion())
},
}
return cmd
}
-97
View File
@@ -1,97 +0,0 @@
package completion
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
func NewCompletionsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "completion <shell>",
Short: "Output shell completion code for the specified shell",
Long: `To load completions:
### Bash
To load completions into the current shell execute:
source <(coolify completion bash)
In order to make the completions permanent, append the line above to
your .bashrc.
### Zsh
If shell completions are not already enabled for your environment need
to enable them. Add the following line to your ~/.zshrc file:
autoload -Uz compinit; compinit
To load completions for each session execute the following commands:
mkdir -p ~/.config/coolify/completion/zsh
coolify completion zsh > ~/.config/coolify/completion/zsh/_coolify
Finally add the following line to your ~/.zshrc file, *before* you
call the compinit function:
fpath+=(~/.config/coolify/completion/zsh)
In the end your ~/.zshrc file should contain the following two lines
in the order given here.
fpath+=(~/.config/coolify/completion/zsh)
# ... anything else that needs to be done before compinit
autoload -Uz compinit; compinit
# ...
You will need to start a new shell for this setup to take effect.
### Fish
To load completions into the current shell execute:
coolify completion fish | source
In order to make the completions permanent execute once:
coolify completion fish > ~/.config/fish/completions/coolify.fish
### PowerShell:
To load completions into the current shell execute:
PS> coolify completion powershell | Out-String | Invoke-Expression
To load completions for every new session, run
and source this file from your PowerShell profile.
PS> coolify completion powershell > coolify.ps1
`,
Args: cli.ExactArgs(1, "<shell>"),
ValidArgs: []string{"bash", "fish", "zsh", "powershell"},
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
var err error
switch args[0] {
case "bash":
err = cmd.Root().GenBashCompletion(os.Stdout)
case "fish":
err = cmd.Root().GenFishCompletion(os.Stdout, true)
case "zsh":
err = cmd.Root().GenZshCompletion(os.Stdout)
case "powershell":
err = cmd.Root().GenPowerShellCompletion(os.Stdout)
default:
err = fmt.Errorf("Unsupported shell: %s", args[0])
}
return err
},
}
return cmd
}
-21
View File
@@ -1,21 +0,0 @@
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/config"
)
// NewConfigCommand creates the config command
func NewConfigCommand() *cobra.Command {
return &cobra.Command{
Use: "config",
Short: "Show configuration file location",
Long: "Display the path to the Coolify CLI configuration file",
Run: func(_ *cobra.Command, _ []string) {
fmt.Println(config.Path())
},
}
}
-52
View File
@@ -1,52 +0,0 @@
package config
import (
"strings"
"testing"
"github.com/coollabsio/coolify-cli/internal/config"
)
func TestNewConfigCommand(t *testing.T) {
cmd := NewConfigCommand()
if cmd == nil {
t.Fatal("NewConfigCommand() returned nil")
}
if cmd.Use != "config" {
t.Errorf("Expected Use to be 'config', got '%s'", cmd.Use)
}
if cmd.Short == "" {
t.Error("Short description should not be empty")
}
if cmd.Long == "" {
t.Error("Long description should not be empty")
}
if cmd.Run == nil {
t.Error("Run function should not be nil")
}
}
func TestConfigCommand_Output(t *testing.T) {
// Test that the command returns the expected config path
expectedPath := config.Path()
// The path should not be empty
if expectedPath == "" {
t.Error("Expected config path to not be empty")
}
// The path should end with config.json
if !strings.HasSuffix(expectedPath, "config.json") {
t.Errorf("Expected path to end with 'config.json', got '%s'", expectedPath)
}
// The path should contain the coolify directory
if !strings.Contains(expectedPath, "coolify") {
t.Errorf("Expected path to contain 'coolify', got '%s'", expectedPath)
}
}
-93
View File
@@ -1,93 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/config"
)
// NewAddCommand creates the add command
func NewAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <context_name> <url> <token>",
Example: `context add myserver https://coolify.example.com your-api-token`,
Args: cli.ExactArgs(3, "<context_name> <url> <token>"),
Short: "Add a new context",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
host := args[1]
token := args[2]
force, _ := cmd.Flags().GetBool("force")
setDefault, _ := cmd.Flags().GetBool("default")
instances := viper.Get("instances").([]any)
// Check if instance already exists
for _, instance := range instances {
instanceMap := instance.(map[string]any)
if instanceMap["name"] == name {
if force {
instanceMap["token"] = token
if setDefault {
// Remove default from all instances
for _, inst := range instances {
instMap := inst.(map[string]any)
instMap["default"] = false
}
instanceMap["default"] = true
fmt.Printf("%s already exists. Force overwriting. Setting it as default.\n", name)
} else {
fmt.Printf("%s already exists. Force overwriting.\n", name)
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
fmt.Printf("%s already exists.\n", name)
fmt.Println("\nNote: Use --force to force overwrite.")
return nil
}
}
// Add new instance
newInstance := config.Instance{
Name: name,
FQDN: host,
Token: token,
Default: false,
}
if setDefault {
// Remove default from all instances
for _, inst := range instances {
instMap := inst.(map[string]any)
instMap["default"] = false
}
newInstance.Default = true
fmt.Printf("Context '%s' added and set as default.\n", newInstance.Name)
} else {
fmt.Printf("Context '%s' added successfully.\n", newInstance.Name)
}
instances = append(instances, newInstance)
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
},
}
cmd.Flags().BoolP("default", "d", false, "Set as default context")
cmd.Flags().BoolP("force", "f", false, "Force overwrite if context already exists")
return cmd
}
-28
View File
@@ -1,28 +0,0 @@
package context
import (
"github.com/spf13/cobra"
)
// NewContextCommand creates the context parent command
func NewContextCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "context",
Short: "Manage Coolify contexts",
Long: `Manage Coolify contexts. A context contains the configuration (URL and token) for connecting to Coolify.`,
}
// Add subcommands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewAddCommand())
cmd.AddCommand(NewDeleteCommand())
cmd.AddCommand(NewUseCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewSetTokenCommand())
cmd.AddCommand(NewSetDefaultCommand())
cmd.AddCommand(NewVersionCommand())
cmd.AddCommand(NewVerifyCommand())
return cmd
}
-54
View File
@@ -1,54 +0,0 @@
package context
import (
"fmt"
"slices"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewDeleteCommand creates the delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <context_name>",
Example: `context delete myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Delete a context",
RunE: func(_ *cobra.Command, args []string) error {
Name := args[0]
instances := viper.Get("instances").([]interface{})
for i, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == Name {
instances = slices.Delete(instances, i, i+1)
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
if instanceMap["default"] == true {
if len(instances) > 0 {
instances[0].(map[string]interface{})["default"] = true
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
newDefaultName := instances[0].(map[string]interface{})["name"]
fmt.Printf("Context '%s' deleted. '%s' is now the default context.\n", Name, newDefaultName)
} else {
fmt.Printf("Context '%s' deleted. No contexts remaining.\n", Name)
}
} else {
fmt.Printf("Context '%s' deleted.\n", Name)
}
return nil
}
}
return fmt.Errorf("context '%s' not found", Name)
},
}
}
-70
View File
@@ -1,70 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/config"
"github.com/coollabsio/coolify-cli/internal/output"
)
// NewGetCommand creates the get command
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <context_name>",
Example: `context get myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Get details of a specific context",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
instancesRaw := viper.Get("instances")
if instancesRaw == nil {
instancesRaw = []any{}
}
instancesInterface := instancesRaw.([]any)
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Convert interface{} to config.Instance structs
var instances []config.Instance
for _, item := range instancesInterface {
itemMap := item.(map[string]any)
instance := config.Instance{
Name: getString(itemMap, "name"),
FQDN: getString(itemMap, "fqdn"),
Token: getString(itemMap, "token"),
Default: getBool(itemMap, "default"),
}
instances = append(instances, instance)
}
// If a name was provided, filter to that single instance
var results []config.Instance
for _, inst := range instances {
if inst.Name == name {
results = append(results, inst)
break
}
}
if len(results) == 0 {
return fmt.Errorf("Context '%s' not found", name)
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(results)
},
}
}
-70
View File
@@ -1,70 +0,0 @@
package context
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/config"
"github.com/coollabsio/coolify-cli/internal/output"
)
// NewListCommand creates the list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all configured contexts",
RunE: func(cmd *cobra.Command, _ []string) error {
// Get instances from viper (returns []interface{})
instancesRaw := viper.Get("instances")
if instancesRaw == nil {
instancesRaw = []interface{}{}
}
instancesInterface := instancesRaw.([]interface{})
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Convert interface{} to config.Instance structs
var instances []config.Instance
for _, item := range instancesInterface {
itemMap := item.(map[string]any)
instance := config.Instance{
Name: getString(itemMap, "name"),
FQDN: getString(itemMap, "fqdn"),
Token: getString(itemMap, "token"),
Default: getBool(itemMap, "default"),
}
instances = append(instances, instance)
}
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(instances)
},
}
}
// Helper functions to safely extract values from map
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
func getBool(m map[string]interface{}, key string) bool {
if val, ok := m[key]; ok {
if b, ok := val.(bool); ok {
return b
}
}
return false
}
-66
View File
@@ -1,66 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewSetTokenCommand creates the set-token command
func NewSetDefaultCommand() *cobra.Command {
return &cobra.Command{
Use: "set-default <context_name>",
Example: `context set-default myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Set a context as the default",
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
raw := viper.Get("instances")
instances, ok := raw.([]interface{})
if !ok {
return fmt.Errorf("invalid instances configuration")
}
// Check if instance exists
var found bool
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
found = true
instanceMap["default"] = true
}
}
if !found {
return fmt.Errorf("Context '%s' not found", name)
}
// Only unset other defaults if we found the target instance
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val != name {
instanceMap["default"] = false
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
// Show the list after updating
return NewListCommand().RunE(cmd, args)
},
}
}
-48
View File
@@ -1,48 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewSetTokenCommand creates the set-token command
func NewSetTokenCommand() *cobra.Command {
return &cobra.Command{
Use: "set-token <context_name> <token>",
Example: `context set-token myserver your-new-api-token`,
Args: cli.ExactArgs(2, "<context_name> <token>"),
Short: "Update the API token for a context",
RunE: func(_ *cobra.Command, args []string) error {
name := args[0]
token := args[1]
var found interface{}
for _, instance := range viper.Get("instances").([]interface{}) {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == name {
found = instanceMap
break
}
}
if found == nil {
return fmt.Errorf("context '%s' not found", name)
}
instances := viper.Get("instances").([]interface{})
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == name {
instanceMap["token"] = token
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to update token for context '%s': %w", name, err)
}
fmt.Printf("Token updated for context '%s'.\n", name)
return nil
},
}
}
-91
View File
@@ -1,91 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewUpdateCommand creates the update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <context_name>",
Example: `context update myserver --name newname --url https://new.coolify.com --token newtoken`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Update a context's properties (name, URL, token)",
RunE: func(cmd *cobra.Command, args []string) error {
oldName := args[0]
instances := viper.Get("instances").([]interface{})
// Get flags
newName, _ := cmd.Flags().GetString("name")
newURL, _ := cmd.Flags().GetString("url")
newToken, _ := cmd.Flags().GetString("token")
// Check if at least one flag is provided
if newName == "" && newURL == "" && newToken == "" {
return fmt.Errorf("at least one of --name, --url, or --token must be provided")
}
// Find the context
var found bool
var contextToUpdate map[string]interface{}
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == oldName {
found = true
contextToUpdate = instanceMap
break
}
}
if !found {
return fmt.Errorf("context '%s' not found", oldName)
}
// If renaming, check if new name already exists
if newName != "" && newName != oldName {
for _, instance := range instances {
instanceMap := instance.(map[string]interface{})
if instanceMap["name"] == newName {
return fmt.Errorf("context with name '%s' already exists", newName)
}
}
contextToUpdate["name"] = newName
}
// Update URL if provided
if newURL != "" {
contextToUpdate["fqdn"] = newURL
}
// Update token if provided
if newToken != "" {
contextToUpdate["token"] = newToken
}
// Save changes
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
// Use the new name if renamed, otherwise use old name
finalName := oldName
if newName != "" {
finalName = newName
}
fmt.Printf("Context '%s' updated successfully.\n", finalName)
return nil
},
}
cmd.Flags().StringP("name", "n", "", "New name for the context")
cmd.Flags().StringP("url", "u", "", "New URL for the context")
cmd.Flags().StringP("token", "t", "", "New token for the context")
return cmd
}
-67
View File
@@ -1,67 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewUseCommand creates the use command
func NewUseCommand() *cobra.Command {
return &cobra.Command{
Use: "use <context_name>",
Example: `context use myserver`,
Args: cli.ExactArgs(1, "<context_name>"),
Short: "Switch to a different context (set as default)",
RunE: func(_ *cobra.Command, args []string) error {
name := args[0]
raw := viper.Get("instances")
instances, ok := raw.([]interface{})
if !ok {
return fmt.Errorf("invalid instances configuration")
}
// Check if instance exists
var found bool
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
found = true
break
}
}
if !found {
return fmt.Errorf("Context '%s' not found", name)
}
// Update default
for _, instance := range instances {
instanceMap, ok := instance.(map[string]interface{})
if !ok {
return fmt.Errorf("invalid instance configuration")
}
if val, ok := instanceMap["name"].(string); ok && val == name {
instanceMap["default"] = true
} else {
delete(instanceMap, "default")
}
}
viper.Set("instances", instances)
if err := viper.WriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
fmt.Printf("Switched to context '%s'.\n", name)
return nil
},
}
}
-41
View File
@@ -1,41 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewVerifyCommand creates the verify command for contexts
func NewVerifyCommand() *cobra.Command {
return &cobra.Command{
Use: "verify",
Short: "Verify current context connection and authentication",
Long: `Verify that the current context is properly configured by testing the connection
to the Coolify instance and validating the API token.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get API client - this will use the current default context
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Try to get version - this verifies both connection and authentication
version, err := client.GetVersion(ctx)
if err != nil {
return fmt.Errorf("verification failed: %w", err)
}
// If we got here, connection and authentication are working
fmt.Printf("✓ Connection successful\n")
fmt.Printf("✓ Authentication valid\n")
fmt.Printf("✓ Coolify version: %s\n", version)
return nil
},
}
}
-106
View File
@@ -1,106 +0,0 @@
package context
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coollabsio/coolify-cli/internal/api"
)
// TestVerifyCommand_APIIntegration tests the verify logic using the API client directly
// This tests the core functionality that the verify command relies on
func TestVerifyCommand_APIIntegration(t *testing.T) {
t.Run("successful verification", func(t *testing.T) {
// Create a test HTTP server that responds to /api/v1/version
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/version", r.URL.Path)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("4.0.0-beta.383"))
}))
defer server.Close()
// Create API client and verify connection
client := api.NewClient(server.URL, "test-token")
version, err := client.GetVersion(context.Background())
// Verify results
require.NoError(t, err)
assert.Equal(t, "4.0.0-beta.383", version)
})
t.Run("unauthorized - invalid token", func(t *testing.T) {
// Create a test HTTP server that returns 401
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Invalid token",
})
}))
defer server.Close()
// Create API client with invalid token
client := api.NewClient(server.URL, "invalid-token")
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
assert.True(t, api.IsUnauthorized(err))
})
t.Run("server error", func(t *testing.T) {
// Create a test HTTP server that returns 500
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{
"error": "Internal server error",
})
}))
defer server.Close()
// Create API client
client := api.NewClient(server.URL, "test-token", api.WithRetries(0))
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
var apiErr *api.Error
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, 500, apiErr.StatusCode)
})
t.Run("not found", func(t *testing.T) {
// Create a test HTTP server that returns 404
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Endpoint not found",
})
}))
defer server.Close()
// Create API client
client := api.NewClient(server.URL, "test-token")
_, err := client.GetVersion(context.Background())
// Verify error
require.Error(t, err)
assert.True(t, api.IsNotFound(err))
})
}
// TestNewVerifyCommand tests that the command is properly configured
func TestNewVerifyCommand(t *testing.T) {
cmd := NewVerifyCommand()
assert.Equal(t, "verify", cmd.Use)
assert.NotEmpty(t, cmd.Short)
assert.NotEmpty(t, cmd.Long)
assert.NotNil(t, cmd.RunE)
}
-35
View File
@@ -1,35 +0,0 @@
package context
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
)
// NewVersionCommand creates the version command for contexts
func NewVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Get current context's Coolify version",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Get API client
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Get version using API client
version, err := client.GetVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get version: %w", err)
}
fmt.Println(version)
return nil
},
}
}
+10
View File
@@ -0,0 +1,10 @@
package coolTypes
var Redacted = "********"
type Instance struct {
Name string `json:"name"`
Default bool `json:"default"`
Fqdn string `json:"fqdn"`
Token string `json:"token"`
}
-129
View File
@@ -1,129 +0,0 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCreateCommand creates a new database
func NewCreateCommand() *cobra.Command {
createBackupCmd := &cobra.Command{
Use: "create <database_uuid>",
Short: "Create a new scheduled backup configuration",
Long: `Create a new scheduled backup configuration for a database. Configure frequency, retention, S3 storage, and other backup options.
Example: coolify database backup create abc123 --frequency "0 0 * * *" --enabled`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.436"); err != nil {
return err
}
req := &models.DatabaseBackupCreateRequest{}
// Apply flags if provided
if cmd.Flags().Changed("frequency") {
frequency, _ := cmd.Flags().GetString("frequency")
req.Frequency = &frequency
}
if cmd.Flags().Changed("enabled") {
enabled, _ := cmd.Flags().GetBool("enabled")
req.Enabled = &enabled
}
if cmd.Flags().Changed("save-s3") {
saveS3, _ := cmd.Flags().GetBool("save-s3")
req.SaveS3 = &saveS3
}
if cmd.Flags().Changed("s3-storage-uuid") {
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
req.S3StorageUUID = &s3UUID
}
if cmd.Flags().Changed("databases") {
databases, _ := cmd.Flags().GetString("databases")
req.DatabasesToBackup = &databases
}
if cmd.Flags().Changed("dump-all") {
dumpAll, _ := cmd.Flags().GetBool("dump-all")
req.DumpAll = &dumpAll
}
if cmd.Flags().Changed("retention-amount-locally") {
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
req.DatabaseBackupRetentionAmountLocally = &amount
}
if cmd.Flags().Changed("retention-days-locally") {
days, _ := cmd.Flags().GetInt("retention-days-locally")
req.DatabaseBackupRetentionDaysLocally = &days
}
if cmd.Flags().Changed("retention-storage-locally") {
storage, _ := cmd.Flags().GetString("retention-storage-locally")
req.DatabaseBackupRetentionMaxStorageLocally = &storage
}
if cmd.Flags().Changed("retention-amount-s3") {
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
req.DatabaseBackupRetentionAmountS3 = &amount
}
if cmd.Flags().Changed("retention-days-s3") {
days, _ := cmd.Flags().GetInt("retention-days-s3")
req.DatabaseBackupRetentionDaysS3 = &days
}
if cmd.Flags().Changed("retention-storage-s3") {
storage, _ := cmd.Flags().GetString("retention-storage-s3")
req.DatabaseBackupRetentionMaxStorageS3 = &storage
}
if cmd.Flags().Changed("timeout") {
timeout, _ := cmd.Flags().GetInt("timeout")
req.Timeout = &timeout
}
if cmd.Flags().Changed("disable-local") {
disableLocal, _ := cmd.Flags().GetBool("disable-local")
req.DisableLocalBackup = &disableLocal
}
dbService := service.NewDatabaseService(client)
backup, err := dbService.CreateBackup(ctx, dbUUID, req)
if err != nil {
return fmt.Errorf("failed to create backup: %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(backup)
},
}
createBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression, e.g., '0 0 * * *' for daily)")
createBackupCmd.Flags().Bool("enabled", false, "Enable backup schedule")
createBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
createBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
createBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
createBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
createBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
createBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
createBackupCmd.Flags().String("retention-max-storage-locally", "", "Max storage for local backups (e.g., '1GB', '500MB')")
createBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
createBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
createBackupCmd.Flags().String("retention-max-storage-s3", "", "Max storage for S3 backups (e.g., '1GB', '500MB')")
createBackupCmd.Flags().Int("timeout", 0, "Backup timeout in seconds")
createBackupCmd.Flags().Bool("disable-local-backup", false, "Disable local backup storage")
return createBackupCmd
}
-63
View File
@@ -1,63 +0,0 @@
package backup
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteExecutionCommand lists all databases
func NewDeleteExecutionCommand() *cobra.Command {
deleteBackupExecutionCmd := &cobra.Command{
Use: "delete-execution <database_uuid> <backup_uuid> <execution_uuid>",
Short: "Delete backup execution",
Long: `Delete a specific backup execution and optionally from S3. First UUID is the database, second is the backup configuration, third is the specific execution.`,
Args: cli.ExactArgs(3, "<database_uuid> <backup_uuid> <execution_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
executionUUID := args[2]
force, _ := cmd.Flags().GetBool("force")
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
if !force {
fmt.Printf("Are you sure you want to delete backup execution %s? (y/N): ", executionUUID)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.DeleteBackupExecution(ctx, dbUUID, backupUUID, executionUUID, deleteS3)
if err != nil {
return fmt.Errorf("failed to delete backup execution: %w", err)
}
fmt.Println("Backup execution deleted successfully")
return nil
},
}
deleteBackupExecutionCmd.Flags().Bool("delete-s3", false, "Delete backup file from S3")
return deleteBackupExecutionCmd
}
-62
View File
@@ -1,62 +0,0 @@
package backup
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand deletes a database
func NewDeleteCommand() *cobra.Command {
deleteBackupCmd := &cobra.Command{
Use: "delete <database_uuid> <backup_uuid>",
Short: "Delete backup configuration",
Long: `Delete a backup configuration and optionally all its executions from S3. First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
force, _ := cmd.Flags().GetBool("force")
deleteS3, _ := cmd.Flags().GetBool("delete-s3")
if !force {
fmt.Printf("Are you sure you want to delete backup configuration %s? (y/N): ", backupUUID)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.DeleteBackup(ctx, dbUUID, backupUUID, deleteS3)
if err != nil {
return fmt.Errorf("failed to delete backup: %w", err)
}
fmt.Println("Backup configuration deleted successfully")
return nil
},
}
deleteBackupCmd.Flags().Bool("delete-s3", false, "Delete backup files from S3")
return deleteBackupCmd
}
-45
View File
@@ -1,45 +0,0 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewExecutionCommand lists all databases
func NewExecutionCommand() *cobra.Command {
return &cobra.Command{
Use: "executions <database_uuid> <backup_uuid>",
Short: "List backup executions",
Long: `List all executions for a backup configuration. First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
executions, err := dbService.ListBackupExecutions(ctx, dbUUID, backupUUID)
if err != nil {
return fmt.Errorf("failed to list backup executions: %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(executions)
},
}
}
-44
View File
@@ -1,44 +0,0 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand lists all databases
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <database_uuid>",
Short: "List all backup configurations for a database",
Long: `List all backup configurations for a specific database.`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
backups, err := dbService.ListBackups(ctx, dbUUID)
if err != nil {
return fmt.Errorf("failed to list backups: %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(backups)
},
}
}
-46
View File
@@ -1,46 +0,0 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewTriggerCommand triggers a database backup
func NewTriggerCommand() *cobra.Command {
return &cobra.Command{
Use: "trigger <database_uuid> <backup_uuid>",
Short: "Trigger immediate backup",
Long: `Trigger an immediate backup for a specific backup configuration. First UUID is the database, second is the specific backup configuration to trigger.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
// Trigger immediate backup by updating with backup_now flag
req := &models.DatabaseBackupUpdateRequest{
BackupNow: cli.BoolPtr(true),
}
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
if err != nil {
return fmt.Errorf("failed to trigger backup: %w", err)
}
fmt.Println("Immediate backup triggered successfully")
return nil
},
}
}
-125
View File
@@ -1,125 +0,0 @@
package backup
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewUpdateCommand updates a database
func NewUpdateCommand() *cobra.Command {
updateBackupCmd := &cobra.Command{
Use: "update <database_uuid> <backup_uuid>",
Short: "Update backup configuration",
Long: `Update a backup configuration settings (frequency, retention, S3, etc.). First UUID is the database, second is the specific backup configuration.`,
Args: cli.ExactArgs(2, "<database_uuid> <backup_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
backupUUID := args[1]
req := &models.DatabaseBackupUpdateRequest{}
hasChanges := false
if cmd.Flags().Changed("enabled") {
enabled, _ := cmd.Flags().GetBool("enabled")
req.Enabled = &enabled
hasChanges = true
}
if cmd.Flags().Changed("frequency") {
freq, _ := cmd.Flags().GetString("frequency")
req.Frequency = &freq
hasChanges = true
}
if cmd.Flags().Changed("save-s3") {
saveS3, _ := cmd.Flags().GetBool("save-s3")
req.SaveS3 = &saveS3
hasChanges = true
}
if cmd.Flags().Changed("s3-storage-uuid") {
s3UUID, _ := cmd.Flags().GetString("s3-storage-uuid")
req.S3StorageUUID = &s3UUID
hasChanges = true
}
if cmd.Flags().Changed("databases-to-backup") {
dbs, _ := cmd.Flags().GetString("databases-to-backup")
req.DatabasesToBackup = &dbs
hasChanges = true
}
if cmd.Flags().Changed("dump-all") {
dumpAll, _ := cmd.Flags().GetBool("dump-all")
req.DumpAll = &dumpAll
hasChanges = true
}
// Retention settings
if cmd.Flags().Changed("retention-amount-locally") {
amount, _ := cmd.Flags().GetInt("retention-amount-locally")
req.DatabaseBackupRetentionAmountLocally = &amount
hasChanges = true
}
if cmd.Flags().Changed("retention-days-locally") {
days, _ := cmd.Flags().GetInt("retention-days-locally")
req.DatabaseBackupRetentionDaysLocally = &days
hasChanges = true
}
if cmd.Flags().Changed("retention-max-storage-locally") {
storage, _ := cmd.Flags().GetInt("retention-max-storage-locally")
req.DatabaseBackupRetentionMaxStorageLocally = &storage
hasChanges = true
}
if cmd.Flags().Changed("retention-amount-s3") {
amount, _ := cmd.Flags().GetInt("retention-amount-s3")
req.DatabaseBackupRetentionAmountS3 = &amount
hasChanges = true
}
if cmd.Flags().Changed("retention-days-s3") {
days, _ := cmd.Flags().GetInt("retention-days-s3")
req.DatabaseBackupRetentionDaysS3 = &days
hasChanges = true
}
if cmd.Flags().Changed("retention-max-storage-s3") {
storage, _ := cmd.Flags().GetInt("retention-max-storage-s3")
req.DatabaseBackupRetentionMaxStorageS3 = &storage
hasChanges = true
}
if !hasChanges {
return fmt.Errorf("no fields to update")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.UpdateBackup(ctx, dbUUID, backupUUID, req)
if err != nil {
return fmt.Errorf("failed to update backup: %w", err)
}
fmt.Println("Backup configuration updated successfully")
return nil
},
}
updateBackupCmd.Flags().Bool("enabled", false, "Enable or disable backup")
updateBackupCmd.Flags().String("frequency", "", "Backup frequency (cron expression)")
updateBackupCmd.Flags().Bool("save-s3", false, "Save backups to S3")
updateBackupCmd.Flags().String("s3-storage-uuid", "", "S3 storage UUID")
updateBackupCmd.Flags().String("databases-to-backup", "", "Comma-separated list of databases to backup")
updateBackupCmd.Flags().Bool("dump-all", false, "Dump all databases")
updateBackupCmd.Flags().Int("retention-amount-locally", 0, "Number of backups to retain locally")
updateBackupCmd.Flags().Int("retention-days-locally", 0, "Days to retain backups locally")
updateBackupCmd.Flags().Int("retention-max-storage-locally", 0, "Max storage for local backups (MB)")
updateBackupCmd.Flags().Int("retention-amount-s3", 0, "Number of backups to retain in S3")
updateBackupCmd.Flags().Int("retention-days-s3", 0, "Days to retain backups in S3")
updateBackupCmd.Flags().Int("retention-max-storage-s3", 0, "Max storage for S3 backups (MB)")
return updateBackupCmd
}
-287
View File
@@ -1,287 +0,0 @@
package database
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <type>",
Short: "Create a new database",
Long: `Create a new database of the specified type.
Supported types: postgresql, mysql, mariadb, mongodb, redis, keydb, clickhouse, dragonfly
Examples:
coolify databases create postgresql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production
coolify databases create mysql --server-uuid=<uuid> --project-uuid=<uuid> --environment-name=production --name="My MySQL"`,
Args: cli.ExactArgs(1, "<type>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbType := args[0]
validTypes := []string{"postgresql", "mysql", "mariadb", "mongodb", "redis", "keydb", "clickhouse", "dragonfly"}
isValid := false
for _, t := range validTypes {
if t == dbType {
isValid = true
break
}
}
if !isValid {
return fmt.Errorf("invalid database type '%s'. Valid types: %s", dbType, strings.Join(validTypes, ", "))
}
serverUUID, _ := cmd.Flags().GetString("server-uuid")
projectUUID, _ := cmd.Flags().GetString("project-uuid")
environmentName, _ := cmd.Flags().GetString("environment-name")
environmentUUID, _ := cmd.Flags().GetString("environment-uuid")
if serverUUID == "" || projectUUID == "" {
return fmt.Errorf("--server-uuid and --project-uuid are required")
}
if environmentName == "" && environmentUUID == "" {
return fmt.Errorf("either --environment-name or --environment-uuid must be provided")
}
req := &models.DatabaseCreateRequest{
ServerUUID: serverUUID,
ProjectUUID: projectUUID,
}
if environmentName != "" {
req.EnvironmentName = &environmentName
}
if environmentUUID != "" {
req.EnvironmentUUID = &environmentUUID
}
// Common flags
if cmd.Flags().Changed("name") {
name, _ := cmd.Flags().GetString("name")
req.Name = &name
}
if cmd.Flags().Changed("description") {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
}
if cmd.Flags().Changed("image") {
image, _ := cmd.Flags().GetString("image")
req.Image = &image
}
if cmd.Flags().Changed("destination-uuid") {
dest, _ := cmd.Flags().GetString("destination-uuid")
req.DestinationUUID = &dest
}
if cmd.Flags().Changed("instant-deploy") {
instant, _ := cmd.Flags().GetBool("instant-deploy")
req.InstantDeploy = &instant
}
if cmd.Flags().Changed("is-public") {
isPublic, _ := cmd.Flags().GetBool("is-public")
req.IsPublic = &isPublic
}
if cmd.Flags().Changed("public-port") {
port, _ := cmd.Flags().GetInt("public-port")
req.PublicPort = &port
}
// Resource limits
if cmd.Flags().Changed("limits-memory") {
mem, _ := cmd.Flags().GetString("limits-memory")
req.LimitsMemory = &mem
}
if cmd.Flags().Changed("limits-cpus") {
cpus, _ := cmd.Flags().GetString("limits-cpus")
req.LimitsCpus = &cpus
}
// PostgreSQL specific
if dbType == "postgresql" {
if cmd.Flags().Changed("postgres-user") {
user, _ := cmd.Flags().GetString("postgres-user")
req.PostgresUser = &user
}
if cmd.Flags().Changed("postgres-password") {
pass, _ := cmd.Flags().GetString("postgres-password")
req.PostgresPassword = &pass
}
if cmd.Flags().Changed("postgres-db") {
db, _ := cmd.Flags().GetString("postgres-db")
req.PostgresDB = &db
}
}
// MySQL specific
if dbType == "mysql" {
if cmd.Flags().Changed("mysql-root-password") {
pass, _ := cmd.Flags().GetString("mysql-root-password")
req.MysqlRootPassword = &pass
}
if cmd.Flags().Changed("mysql-user") {
user, _ := cmd.Flags().GetString("mysql-user")
req.MysqlUser = &user
}
if cmd.Flags().Changed("mysql-password") {
pass, _ := cmd.Flags().GetString("mysql-password")
req.MysqlPassword = &pass
}
if cmd.Flags().Changed("mysql-database") {
db, _ := cmd.Flags().GetString("mysql-database")
req.MysqlDatabase = &db
}
}
// MariaDB specific
if dbType == "mariadb" {
if cmd.Flags().Changed("mariadb-root-password") {
pass, _ := cmd.Flags().GetString("mariadb-root-password")
req.MariadbRootPassword = &pass
}
if cmd.Flags().Changed("mariadb-user") {
user, _ := cmd.Flags().GetString("mariadb-user")
req.MariadbUser = &user
}
if cmd.Flags().Changed("mariadb-password") {
pass, _ := cmd.Flags().GetString("mariadb-password")
req.MariadbPassword = &pass
}
if cmd.Flags().Changed("mariadb-database") {
db, _ := cmd.Flags().GetString("mariadb-database")
req.MariadbDatabase = &db
}
}
// MongoDB specific
if dbType == "mongodb" {
if cmd.Flags().Changed("mongo-root-username") {
user, _ := cmd.Flags().GetString("mongo-root-username")
req.MongoInitdbRootUsername = &user
}
if cmd.Flags().Changed("mongo-root-password") {
pass, _ := cmd.Flags().GetString("mongo-root-password")
req.MongoInitdbRootPassword = &pass
}
if cmd.Flags().Changed("mongo-database") {
db, _ := cmd.Flags().GetString("mongo-database")
req.MongoInitdbDatabase = &db
}
}
// Redis specific
if dbType == "redis" {
if cmd.Flags().Changed("redis-password") {
pass, _ := cmd.Flags().GetString("redis-password")
req.RedisPassword = &pass
}
}
// KeyDB specific
if dbType == "keydb" {
if cmd.Flags().Changed("keydb-password") {
pass, _ := cmd.Flags().GetString("keydb-password")
req.KeydbPassword = &pass
}
}
// Clickhouse specific
if dbType == "clickhouse" {
if cmd.Flags().Changed("clickhouse-admin-user") {
user, _ := cmd.Flags().GetString("clickhouse-admin-user")
req.ClickhouseAdminUser = &user
}
if cmd.Flags().Changed("clickhouse-admin-password") {
pass, _ := cmd.Flags().GetString("clickhouse-admin-password")
req.ClickhouseAdminPassword = &pass
}
}
// Dragonfly specific
if dbType == "dragonfly" {
if cmd.Flags().Changed("dragonfly-password") {
pass, _ := cmd.Flags().GetString("dragonfly-password")
req.DragonflyPassword = &pass
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
database, err := dbService.Create(ctx, dbType, req)
if err != nil {
return fmt.Errorf("failed to create database: %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(database)
},
}
// Common flags
cmd.Flags().String("server-uuid", "", "Server UUID (required)")
cmd.Flags().String("project-uuid", "", "Project UUID (required)")
cmd.Flags().String("environment-name", "", "Environment name")
cmd.Flags().String("environment-uuid", "", "Environment UUID")
cmd.Flags().String("destination-uuid", "", "Destination UUID if server has multiple destinations")
cmd.Flags().String("name", "", "Database name")
cmd.Flags().String("description", "", "Database description")
cmd.Flags().String("image", "", "Docker image")
cmd.Flags().Bool("instant-deploy", false, "Deploy immediately after creation")
cmd.Flags().Bool("is-public", false, "Make database publicly accessible")
cmd.Flags().Int("public-port", 0, "Public port")
cmd.Flags().String("limits-memory", "", "Memory limit (e.g., '512m', '2g')")
cmd.Flags().String("limits-cpus", "", "CPU limit (e.g., '0.5', '2')")
// PostgreSQL flags
cmd.Flags().String("postgres-user", "", "PostgreSQL user")
cmd.Flags().String("postgres-password", "", "PostgreSQL password")
cmd.Flags().String("postgres-db", "", "PostgreSQL database name")
// MySQL flags
cmd.Flags().String("mysql-root-password", "", "MySQL root password")
cmd.Flags().String("mysql-user", "", "MySQL user")
cmd.Flags().String("mysql-password", "", "MySQL password")
cmd.Flags().String("mysql-database", "", "MySQL database name")
// MariaDB flags
cmd.Flags().String("mariadb-root-password", "", "MariaDB root password")
cmd.Flags().String("mariadb-user", "", "MariaDB user")
cmd.Flags().String("mariadb-password", "", "MariaDB password")
cmd.Flags().String("mariadb-database", "", "MariaDB database name")
// MongoDB flags
cmd.Flags().String("mongo-root-username", "", "MongoDB root username")
cmd.Flags().String("mongo-root-password", "", "MongoDB root password")
cmd.Flags().String("mongo-database", "", "MongoDB database name")
// Redis flags
cmd.Flags().String("redis-password", "", "Redis password")
// KeyDB flags
cmd.Flags().String("keydb-password", "", "KeyDB password")
// Clickhouse flags
cmd.Flags().String("clickhouse-admin-user", "", "Clickhouse admin user")
cmd.Flags().String("clickhouse-admin-password", "", "Clickhouse admin password")
// Dragonfly flags
cmd.Flags().String("dragonfly-password", "", "Dragonfly password")
return cmd
}
-43
View File
@@ -1,43 +0,0 @@
package database
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/database/backup"
)
// NewDatabaseCommand creates the database parent command with all subcommands
func NewDatabaseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "database",
Aliases: []string{"databases", "db", "dbs"},
Short: "Manage Coolify databases",
Long: `Manage Coolify databases (PostgreSQL, MySQL, MongoDB, Redis, MariaDB, KeyDB, Clickhouse, Dragonfly).`,
}
// Add main database commands
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewStartCommand())
cmd.AddCommand(NewStopCommand())
cmd.AddCommand(NewRestartCommand())
cmd.AddCommand(NewCreateCommand())
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
// Add backup subcommand
backupCmd := &cobra.Command{
Use: "backup",
Short: "Manage database backups",
}
backupCmd.AddCommand(backup.NewCreateCommand())
backupCmd.AddCommand(backup.NewListCommand())
backupCmd.AddCommand(backup.NewDeleteCommand())
backupCmd.AddCommand(backup.NewUpdateCommand())
backupCmd.AddCommand(backup.NewTriggerCommand())
backupCmd.AddCommand(backup.NewExecutionCommand())
backupCmd.AddCommand(backup.NewDeleteExecutionCommand())
cmd.AddCommand(backupCmd)
return cmd
}
-68
View File
@@ -1,68 +0,0 @@
package database
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand deletes a database
func NewDeleteCommand() *cobra.Command {
deleteDatabaseCmd := &cobra.Command{
Use: "delete <uuid>",
Short: "Delete a database",
Long: `Delete a database and optionally clean up its configurations, volumes, and networks.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
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")
if !force {
fmt.Printf("Are you sure you want to delete database %s? (y/N): ", uuid)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("error reading input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
fmt.Println("Delete cancelled")
return nil
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.Delete(ctx, uuid, deleteConfigurations, deleteVolumes, dockerCleanup, deleteConnectedNetworks)
if err != nil {
return fmt.Errorf("failed to delete database: %w", err)
}
fmt.Println("Database deleted successfully")
return nil
},
}
deleteDatabaseCmd.Flags().Bool("delete-configurations", true, "Delete configurations")
deleteDatabaseCmd.Flags().Bool("delete-volumes", true, "Delete volumes")
deleteDatabaseCmd.Flags().Bool("docker-cleanup", true, "Run docker cleanup")
deleteDatabaseCmd.Flags().Bool("delete-connected-networks", true, "Delete connected networks")
return deleteDatabaseCmd
}
-46
View File
@@ -1,46 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewGetCommand gets database details
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <uuid>",
Short: "Get database details",
Long: `Get detailed information about a specific database by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
database, err := dbService.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get database: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter("table", output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(database)
},
}
}
-41
View File
@@ -1,41 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand lists all databases
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all databases",
Long: `List all databases in Coolify.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
databases, err := dbService.List(ctx)
if err != nil {
return fmt.Errorf("failed to list databases: %w", err)
}
formatter, err := output.NewFormatter("table", output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(databases)
},
}
}
-38
View File
@@ -1,38 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewRestartCommand restarts a database
func NewRestartCommand() *cobra.Command {
return &cobra.Command{
Use: "restart <uuid>",
Short: "Restart a database",
Long: `Restart a database by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Restart(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to restart database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
-38
View File
@@ -1,38 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewStartCommand starts a database
func NewStartCommand() *cobra.Command {
return &cobra.Command{
Use: "start <uuid>",
Short: "Start a database",
Long: `Start a database by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Start(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to start database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
-38
View File
@@ -1,38 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewStopCommand stops a database
func NewStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <uuid>",
Short: "Stop a database",
Long: `Stop a database by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
response, err := dbService.Stop(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to stop database: %w", err)
}
fmt.Println(response.Message)
return nil
},
}
}
-116
View File
@@ -1,116 +0,0 @@
package database
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewUpdateCommand updates a database
func NewUpdateCommand() *cobra.Command {
updateDatabaseCmd := &cobra.Command{
Use: "update <uuid>",
Short: "Update a database",
Long: `Update a database's configuration by UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
req := &models.DatabaseUpdateRequest{}
hasChanges := false
if cmd.Flags().Changed("name") {
name, _ := cmd.Flags().GetString("name")
req.Name = &name
hasChanges = true
}
if cmd.Flags().Changed("description") {
desc, _ := cmd.Flags().GetString("description")
req.Description = &desc
hasChanges = true
}
if cmd.Flags().Changed("image") {
image, _ := cmd.Flags().GetString("image")
req.Image = &image
hasChanges = true
}
if cmd.Flags().Changed("is-public") {
isPublic, _ := cmd.Flags().GetBool("is-public")
req.IsPublic = &isPublic
hasChanges = true
}
if cmd.Flags().Changed("public-port") {
port, _ := cmd.Flags().GetInt("public-port")
req.PublicPort = &port
hasChanges = true
}
// Resource limits
if cmd.Flags().Changed("limits-memory") {
mem, _ := cmd.Flags().GetString("limits-memory")
req.LimitsMemory = &mem
hasChanges = true
}
if cmd.Flags().Changed("limits-cpus") {
cpus, _ := cmd.Flags().GetString("limits-cpus")
req.LimitsCpus = &cpus
hasChanges = true
}
if !hasChanges {
return fmt.Errorf("no fields to update")
}
// Validate is-public requires public-port
if req.IsPublic != nil && *req.IsPublic {
// If setting to public, check if port is provided or fetch current database to check existing port
if req.PublicPort == nil || *req.PublicPort == 0 {
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
currentDB, err := dbService.Get(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to get current database: %w", err)
}
// Check if database already has a public port
if currentDB.PublicPort == nil || *currentDB.PublicPort == 0 {
return fmt.Errorf("cannot set database as public without a public port. Please provide --public-port")
}
}
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbService := service.NewDatabaseService(client)
err = dbService.Update(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to update database: %w", err)
}
fmt.Println("Database updated successfully")
return nil
},
}
updateDatabaseCmd.Flags().String("name", "", "Database name")
updateDatabaseCmd.Flags().String("description", "", "Database description")
updateDatabaseCmd.Flags().String("image", "", "Docker image")
updateDatabaseCmd.Flags().Bool("is-public", false, "Make database publicly accessible")
updateDatabaseCmd.Flags().Int("public-port", 0, "Public port")
updateDatabaseCmd.Flags().String("limits-memory", "", "Memory limit")
updateDatabaseCmd.Flags().String("limits-cpus", "", "CPU limit")
return updateDatabaseCmd
}
-131
View File
@@ -1,131 +0,0 @@
package deployment
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewBatchCommand deploys multiple resources by name
func NewBatchCommand() *cobra.Command {
cmd := &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: cli.ExactArgs(1, "<names>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
namesStr := args[0]
client, err := cli.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
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
-75
View File
@@ -1,75 +0,0 @@
package deployment
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCancelCommand cancels a deployment
func NewCancelCommand() *cobra.Command {
cmd := &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: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.436"); err != nil {
return err
}
force, err := cmd.Flags().GetBool("force")
if err != nil {
return fmt.Errorf("failed to parse force flag: %w", err)
}
// Prompt for confirmation unless --force is used
if !force {
fmt.Printf("Are you sure you want to cancel deployment %s? (yes/no): ", uuid)
var response string
if _, err := fmt.Scanln(&response); err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
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, err := cmd.Flags().GetString("format")
if err != nil {
return fmt.Errorf("failed to get format flag: %w", err)
}
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(result)
},
}
cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt")
return cmd
}
-21
View File
@@ -1,21 +0,0 @@
package deployment
import "github.com/spf13/cobra"
// NewDeploymentCommand creates the deployment parent command with all subcommands
func NewDeploymentCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy related commands",
}
// Add all deployment subcommands
cmd.AddCommand(NewUUIDCommand())
cmd.AddCommand(NewNameCommand())
cmd.AddCommand(NewBatchCommand())
cmd.AddCommand(NewListCommand())
cmd.AddCommand(NewGetCommand())
cmd.AddCommand(NewCancelCommand())
return cmd
}
-44
View File
@@ -1,44 +0,0 @@
package deployment
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewGetCommand gets deployment details
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <uuid>",
Short: "Get deployment details by UUID",
Long: `Get detailed information about a specific deployment by its UUID.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.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)
},
}
}
-42
View File
@@ -1,42 +0,0 @@
package deployment
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand lists all deployments
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all deployments",
Long: `List all currently running deployments across all resources.`,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
client, err := cli.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)
},
}
}
-79
View File
@@ -1,79 +0,0 @@
package deployment
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewNameCommand deploys a resource by name
func NewNameCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "name <resource_name>",
Short: "Deploy by resource name",
Args: cli.ExactArgs(1, "<resource_name>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
name := args[0]
client, err := cli.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([]ResultDisplay, len(result.Deployments))
for i, dep := range result.Deployments {
displays[i] = ResultDisplay{
Message: dep.Message,
DeploymentUUID: dep.DeploymentUUID,
}
}
return formatter.Format(displays)
}
return formatter.Format(result)
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
-65
View File
@@ -1,65 +0,0 @@
package deployment
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// ResultDisplay represents a deploy result for table display
type ResultDisplay struct {
Message string `json:"message"`
DeploymentUUID string `json:"deployment_uuid"`
}
// NewUUIDCommand deploys a resource by UUID
func NewUUIDCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "uuid <uuid>",
Short: "Deploy by uuid",
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Deploy(ctx, uuid, force)
if err != nil {
return fmt.Errorf("failed to deploy resource: %w", err)
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{})
if err != nil {
return err
}
// For table format, convert deployment info array to display format
if format == output.FormatTable {
displays := make([]ResultDisplay, len(result.Deployments))
for i, dep := range result.Deployments {
displays[i] = ResultDisplay{
Message: dep.Message,
DeploymentUUID: dep.DeploymentUUID,
}
}
return formatter.Format(displays)
}
return formatter.Format(result)
},
}
cmd.Flags().Bool("force", false, "Force deployment")
return cmd
}
-96
View File
@@ -1,96 +0,0 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
)
var docsCmd = &cobra.Command{
Use: "docs",
Short: "Generate documentation",
Hidden: true,
}
var manCmd = &cobra.Command{
Use: "man",
Short: "Generate man pages",
Long: `Generate man pages for all Coolify CLI commands.
The man pages will be written to the specified directory (default: ./man).`,
Example: ` coolify docs man
coolify docs man --output-dir=/usr/local/share/man/man1`,
RunE: func(cmd *cobra.Command, _ []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0750); 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",
}
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",
Aliases: []string{"md"},
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, _ []string) error {
outputDir, _ := cmd.Flags().GetString("output-dir")
// Create output directory if it doesn't exist
if err := os.MkdirAll(outputDir, 0750); 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 NewDocsCommand() *cobra.Command {
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")
return docsCmd
}
+6
View File
@@ -0,0 +1,6 @@
package emoji
const (
CheckMarkButton = "\u2705" // ✅
CrossMark = "\u274c" // ❌
)

Some files were not shown because too many files have changed in this diff Show More