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
318 changed files with 20713 additions and 38165 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 -35
View File
@@ -10,48 +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 }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_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
-75
View File
@@ -1,75 +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 ./...
llms-txt:
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: Regenerate llms.txt
run: go run ./coolify docs llms
- name: Check uncommitted changes
run: git diff --exit-code llms.txt llms-full.txt
- if: failure()
run: echo "::error::llms.txt or llms-full.txt is out of date. Run 'go run ./coolify docs llms' and commit the changes."
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 -74
View File
@@ -1,83 +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
revive:
rules:
- name: var-naming
arguments:
- []
- []
- - skipPackageNameChecks: true
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 -40
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,34 +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]
brews:
- name: coolify-cli
repository:
owner: coollabsio
name: homebrew-coolify-cli
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
directory: Formula
homepage: "https://coolify.io"
description: "CLI tool for interacting with the Coolify API"
license: "MIT"
install: |
bin.install "coolify"
test: |
system "#{bin}/coolify", "version"
- 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)
-670
View File
@@ -1,670 +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
## `coolify init` — WireGuard mesh + Podman bootstrap (alpha, v5)
**This subcommand is an outlier**: it does NOT talk to the Coolify API. It SSHes into remote hosts and installs/configures WireGuard, Podman, the bridge network, and a firewall scaffold. It's the fleet-provisioning command tree consumed by the v5 control plane (coold), split into three intent-scoped subcommands — `bootstrap`, `extend`, `upgrade` — plus a read-only `plan`. Coolify's backend calls `extend` when the operator adds a server and `upgrade` when agent versions move; direct-CLI operators run `bootstrap` for the initial install.
### What it does
- Establishes a full-mesh WireGuard overlay across N hosts.
- Each host gets a mgmt IP `/32` from `--wg-mgmt-pool` (default `100.64.0.0/16`, RFC 6598 CGNAT) on `wg0`.
- For every namespace (see **Namespaces** below; default: just `default`), each host gets a container subnet `/<container-prefix>` carved from the shared `--container-pool` (default `10.210.0.0/16`, default prefix `/24`). Each namespace is owned by its own Podman bridge named `coolify-<namespace>-mesh` (default → `coolify-default-mesh`).
- Installs Podman + enables `podman.socket` + creates every namespace bridge + installs `coolify-mesh-fw.service` (always; required for v5 runtime).
- Downloads and installs coold + corrosion (v5 control-plane agents; always) from GitHub releases on each remote host. Release tag controlled by `--coold-version` / `--corrosion-version` (default `nightly`). coold receives the full namespace list via `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,...` so it can bind DNS and track rules per namespace.
- Installs default-deny firewall scaffold by default — host-global `COOLIFY-INTRA` + empty `COOLIFY-ALLOW` chains, with FORWARD jumps for every namespace subnet. Use `--skip-default-deny` to fall back to blanket-allow (mode A) for testing.
### Architecture (why this layout)
The mgmt pool and container pool are **separate** so the Podman bridge can own the full container `/24` without conflicting with `wg0`. Pattern adopted from uncloud (psviderski/uncloud).
WG config per host (e.g. host A with two namespaces `default` + `alpha`):
```
[Interface]
Address = 100.64.0.1/32 # mgmt IP, NOT in container pool
ListenPort = 51820
PrivateKey = <gen on host>
[Peer] # one per other host
PublicKey = <peer pubkey>
AllowedIPs = 100.64.0.2/32, 10.210.1.0/24, 10.220.1.0/24 # mgmt + every namespace subnet
Endpoint = <peer SSH ip>:51820
```
Critical: `AllowedIPs` lists the peer's full per-namespace `/24`s so the kernel routes each namespace subnet via `wg0`. Namespace order is deterministic (sorted) so `wg0.conf` is stable across re-runs.
Every namespace bridge `coolify-<ns>-mesh` is created with `--disable-dns --label io.coolify.managed=true --label io.coolify.namespace=<ns>` — the bridge gateway `:53` is reserved for coold's embedded cluster DNS (see CONTROL_PLANE.md §5). Pre-alpha networks with `dns_enabled=true` are detected on re-run and recreated.
Firewall service (`coolify-mesh-fw.service`) installed unconditionally and stays host-global:
- POSTROUTING `RETURN` rule per namespace subnet prevents Podman MASQUERADE from rewriting container egress source on `wg0`.
- Mode A (`--skip-default-deny`): blanket FORWARD ACCEPT for every namespace subnet.
- Mode B (default): `COOLIFY-INTRA` chain (ESTABLISHED accept → `COOLIFY-ALLOW` → DROP), FORWARD jumps for `-s/-d <ns-subnet>` per namespace. v5 control plane (coold) fills `COOLIFY-ALLOW`.
### Cross-host vs intra-host firewall
- **Cross-host default-deny WORKS** — those packets cross interfaces (wg0 ↔ bridge) and traverse iptables FORWARD. Empirically verified.
- **Intra-host (same bridge) is NOT enforced** — Linux + netavark + Ubuntu 24.04 quirk: bridge L2 traffic bypasses iptables FORWARD even with `bridge-nf-call-iptables=1`. v5 control plane handles intra-host isolation via per-app podman networks (`--opt isolate=true`), not iptables.
### Subcommands
Three intent-scoped subcommands. Each runs the same probe → plan → filter → apply → verify pipeline; what differs is the filter applied to the action list. The filter lives in `internal/wireguard/intent.go` (`ValidateIntent` + `filterByIntent`). Suppressed actions surface on `plan.Skipped` so the preview shows operators what would have fired and why.
```bash
coolify init plan --servers IP1,IP2,IP3 --ssh-key KEY [--intent bootstrap|extend|upgrade]
coolify init bootstrap --servers IP1,IP2,IP3 --ssh-key KEY [--yes]
coolify init extend --servers IP1,IP2,IP3,IP4 --new-hosts IP4 --ssh-key KEY [--allow-replace]
coolify init upgrade --servers IP1,IP2,IP3 --ssh-key KEY --coold-version v1.7.0 [--allow-nightly]
```
- `plan` is read-only: probes, reconstructs, shows what the selected intent would execute. Default intent is `bootstrap` (broadest preview).
- `bootstrap` is the first-time install — every applicable action on every host. Keeps the interactive alpha gate (unless `--yes`, `COOLIFY_NON_INTERACTIVE=1`, or non-TTY). 2-phase parallel: phase 1 = install + keygen + podman + socket + IP forward. Re-probe. Phase 2 = write WG config + enable/reload service + create podman networks + install firewall + install coold/corrosion (+ scheduler on `--central` + builder on `--builder-hosts`).
- `extend` adds the hosts listed in `--new-hosts` (required subset of `--servers`) to an existing mesh. Brand-new hosts get the full first-time install. Existing hosts get **only peer-refresh** actions (WG config rewrite picks up the new peer's mgmt `/32` + namespace `/24`s in `AllowedIPs`, corrosion peer list refreshed, firewall unit reinstalled only when the namespace list changed). Agent binaries are not re-downloaded on existing hosts. Destructive-replace actions (podman network recreate because of `dns_enabled=true` drift or a subnet/label mismatch) are **blocked on existing hosts** unless `--allow-replace` is passed. The corrosion-schema wipe-DB branch is never unlocked — resolve schema drift with `upgrade` on a fresh schema.
- `upgrade` bumps agent binaries across every host. Only binary-fetch actions (`install-coold`, `install-corrosion`, `install-scheduler`, `install-builder`) and their follow-up service restarts (`install-coold-service`, `install-corrosion-service`, `install-scheduler-service`) run. WG config, podman networks, firewall rules, and the corrosion schema stay untouched. `nightly` tags are rejected by default (they force a re-install every run); pin a version with `--coold-version=v1.7.0` etc. or pass `--allow-nightly`.
`extend` and `upgrade` skip the interactive alpha gate because they are the paths the Coolify backend calls in production. `bootstrap` keeps the gate for direct-CLI runs.
### Flags (defined in `cmd/init/flags.go`)
Persistent (inherited by `plan`, `bootstrap`, `extend`, `upgrade`):
| Flag | Default | Purpose |
|---|---|---|
| `--servers` | required | comma-separated SSH IPs (full list of every host in the mesh, including already-converged ones on extend/upgrade) |
| `--ssh-key` | required | path to SSH private key |
| `--ssh-passphrase-prompt` | false | prompt for key passphrase (also reads `COOLIFY_SSH_PASSPHRASE` env) |
| `--ssh-user` | `root` | SSH user |
| `--ssh-port` | `22` | SSH port |
| `--wg-mgmt-pool` | `100.64.0.0/16` | mgmt IP pool, /32 per host on wg0 |
| `--container-pool` | `10.210.0.0/16` | container pool, carved per host |
| `--container-prefix` | `24` | per-host container subnet prefix |
| `--wg-interface` | `wg0` | WG iface name on remote |
| `--wg-listen-port` | `51820` | WG UDP port |
| `--namespaces` | `default` | comma-separated list of namespaces. Each creates its own `coolify-<ns>-mesh` bridge with its own per-host `/24` carved from `--container-pool` |
| `--skip-default-deny` | false | skip the default-deny firewall scaffold. Default installs COOLIFY-INTRA + empty COOLIFY-ALLOW chains for cross-host deny |
| `--coold-version` | `nightly` | release tag to download for coold (e.g. `nightly`, `v1.2.3`). `nightly` always re-downloads on every run; pinned tags skip when the on-host version marker matches. Fetched from `coollabsio/coold` GitHub releases on the remote host. |
| `--corrosion-version` | `nightly` | release tag to download for corrosion. Same drift semantics as `--coold-version`. Fetched from `coollabsio/corrosion` GitHub releases. |
| `--scheduler-version` | `nightly` | release tag for scheduler (only fetched when `--central` is set). |
| `--corrosion-gossip-port` | `8787` | corrosion SWIM gossip port (bound to wg0 mgmt IP) |
| `--corrosion-api-port` | `8080` | corrosion HTTP API port (bound to 127.0.0.1) |
| `--central` | `""` | SSH address of the central VM (must be in `--servers`). When set, scheduler installs there and per-host JWTs are pushed to every peer. Empty = skip scheduler setup. |
| `--enable-builder` | true | cluster-wide shorthand: enable the builder capability on every host (requires `--central`). Ignored when `--builder-hosts` is set. |
| `--builder-hosts` | `[]` | explicit subset of `--servers` to enroll with the builder capability. Takes precedence over `--enable-builder`. |
| `--builder-capacity` | `2` | concurrent builds per host (`COOLD_BUILDER_CAPACITY`) |
| `--builder-cpu-quota` | `200%` | systemd CPUQuota per build subprocess |
| `--builder-memory-max` | `2G` | systemd MemoryMax per build subprocess |
| `--builder-timeout-secs` | `1800` | wall-clock cap per build |
| `--concurrency` | `10` | parallel SSH connections |
| `--ssh-timeout` | `30s` | SSH connect timeout |
| `--yes`, `-y` | false | skip alpha confirmation prompt (honored by `bootstrap`; `extend` and `upgrade` always skip it) |
Subcommand-local:
| Flag | Subcommand | Default | Purpose |
|---|---|---|---|
| `--intent` | `plan` | `bootstrap` | preview filter: `bootstrap` (all actions), `extend` (treat `--new-hosts` as fresh, existing hosts peer-refresh only), `upgrade` (version bumps only) |
| `--new-hosts` | `extend` | required | comma-separated subset of `--servers` that is brand-new this run. Only these hosts receive the full install; all other hosts get peer-refresh only. |
| `--allow-replace` | `extend` | false | unlock destructive-replace actions on existing hosts (e.g. recreating a drifted podman bridge). Off by default — drifted existing hosts surface as skipped actions. |
| `--allow-nightly` | `upgrade` | false | permit `nightly` as a version tag. Off by default because `nightly` re-installs every run instead of only when the pinned version changes. |
### Namespaces
Namespaces are the tenancy unit the mesh carries. A namespace is:
- **A podman bridge network** on every host, named `coolify-<ns>-mesh` (default → `coolify-default-mesh`), labelled `io.coolify.managed=true` + `io.coolify.namespace=<ns>`.
- **A per-host `/<container-prefix>` subnet** carved from the shared `--container-pool`. Allocation is deterministic across `(namespace, host)` pairs so re-runs reproduce the same layout.
- **A DNS view** coold serves on that bridge's gateway: records take the shape `<container>.<namespace>.coolify.internal`. Bare `<container>.coolify.internal` is deliberately NXDOMAIN — callers must fully qualify.
- **A firewall tenant**: allow-rule cids hash the namespace in, so identical src/dst/proto/port tuples in different namespaces are distinct rules. iptables chains stay host-global (`COOLIFY-INTRA` / `COOLIFY-ALLOW`) for alpha; namespace isolation comes from separate podman bridges + namespace-qualified allow rules.
Config knobs:
- `coolify init bootstrap --namespaces default,alpha,beta` provisions every namespace on every host in one pass. Re-running `bootstrap` (or running `extend` with the new namespace in `--namespaces`) installs only the new per-namespace assets (bridge + FORWARD jumps + WG `AllowedIPs` refresh + firewall unit reinstall because of unit-hash drift). Removing a namespace is **not** idempotent today — destroy/rebuild is the documented path for alpha.
- `coolify firewall --namespace <ns>` (default `default`) scopes allow/revoke/list/containers to one namespace. `list` and `containers` also accept `--all-namespaces` for cross-namespace observability.
- coold receives the full namespace list via `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,…` (see `internal/services/coold.go`). DNS binds and rule storage derive from that.
Deliberately deferred (tracked in the active plan):
- Per-namespace iptables chains. Host-global keeps kernel state simple; revisit when a user asks for kernel-enforced per-namespace default-deny.
- Cross-namespace L2 bridging. Different namespaces = different podman bridges = no intra-host connectivity. Cross-namespace flows require explicit allow rules + dual-attach containers.
- Wildcard / DNS search domain. Start strict; loosen once real workloads push back.
### Code layout
- `cmd/common/` — flag structs shared between `init` and `firewall`.
- `sshmesh.go``SSHMeshFlags` + `BindSSHMeshFlags`, `BuildSSHClient`, `ParseSSHTimeout`, `ResolvePassphrase`, `Validate`.
- `meshnet.go``MeshNetFlags` (namespaces + container pool/prefix) + `BindMeshNetMultiFlags` (init-style: many namespaces) + `BindMeshNetSingleFlags` (firewall-style: one namespace) + `PodmanNetworkFor(ns)` + `ValidateNamespaces` / `ValidateNamespace` (DNS-label check).
- `cmd/init/` — Cobra subcommands (`init`, `init plan`, `init bootstrap`, `init extend`, `init upgrade`).
- `flags.go``InitFlags` struct (embeds `common.SSHMeshFlags` + `common.MeshNetFlags`) + bindings + SSH client builder. Carries subcommand-scoped knobs: `NewHosts`, `AllowReplace`, `AllowNightly`, `Intent`.
- `desired.go``buildDesired(flags)`: flag → `wireguard.DesiredMesh`. One source of truth so every subcommand produces the same struct modulo `Intent`.
- `plan.go``runPlan`: validate, `buildDesired`, `ValidateIntent`, build SSH client, probe, `BuildPlan`, render actions + skipped rows. `--intent` flag selects the filter for preview.
- `apply.go``runApply(ctx, cmd, flags, applyOptions)`: shared pipeline for all three executing subcommands. `applyOptions{SkipAlphaGate, Header}` differentiates them.
- `bootstrap.go``NewBootstrapCommand`: sets `flags.Intent = "bootstrap"`, keeps alpha gate.
- `extend.go``NewExtendCommand`: binds `--new-hosts` + `--allow-replace`, validates subset, sets `flags.Intent = "extend"`, skips alpha gate.
- `upgrade.go``NewUpgradeCommand`: binds `--allow-nightly`, sets `flags.Intent = "upgrade"`, skips alpha gate.
- `init.go` — registers the four subcommands; package is `initcmd` (not `init` — Go reserved keyword).
- `internal/wireguard/` — pure Go logic (no SSH, no I/O — `apply.go` is the SSH boundary).
- `state.go``ServerState` (with `Namespaces map[string]*NamespaceServerState`), `MeshState`, `DesiredMesh` (with `Intent`, `NewHosts`, `AllowReplace`, `AllowNightly`). `Intent` enum: `IntentBootstrap` (zero value), `IntentExtend`, `IntentUpgrade`.
- `intent.go``ValidateIntent` (pre-plan invariants: extend needs `NewHosts ⊆ Hosts`; upgrade rejects nightly unless opted-in), `filterByIntent` (mutates `plan.Actions` + `plan.Skipped`), `categorize` (action → `catSafeAlways` / `catPeerRefresh` / `catDestructiveReplace` / `catVersionBump` / `catWipeDB` / `catCorrosionSchemaFirstWrite`).
- `subnet.go``Allocate` (per `(namespace, host)` pair: `map[ns]map[host]*net.IPNet`) + `AllocateMgmtIPs` (per-host /32) + conflict detection. Provably stable: adding host D never shifts A/B/C.
- `config.go``RenderConfig` + `WriteConfigCommand` for `wg0.conf` (Address /32, AllowedIPs = mgmt /32 + every peer namespace subnet, deterministic order).
- `reconstruct.go``Probe` (per-namespace podman network inspect + label read) + `Reconstruct` (parallel) + `parseConfigFile`.
- `plan.go``BuildPlan` (pure: desired - actual = actions, then `ValidateIntent` + `filterByIntent`). `Plan.Skipped []SkippedAction` carries intent-filtered entries with reasons. Podman actions carry a `Namespace` field; one create/recreate action per namespace per host.
- `apply.go``ApplyMesh` (2-phase fanout via `internal/ssh/fanout.go`). Phase 2 loops over namespaces per host; firewall unit takes the union of every namespace subnet.
- `firewall.go``coolify-mesh-fw.service` unit generator (two-mode: blanket allow vs default-deny, one FORWARD/POSTROUTING pair per namespace subnet).
- `internal/ssh/` — generic SSH runner + parallel `ForEachServer[T]`.
- `test/fixtures/wg/wg0.conf` — fixture for parser tests.
### Key invariants
- **Reconstructed-only state**: no local state file. Every run re-probes via SSH. State lives on the hosts.
- **Idempotent**: re-running with no changes produces an empty plan. State drift triggers re-converge (e.g. flipping `--skip-default-deny` reinstalls the firewall service; bumping `--coold-version` re-fetches the binary).
- **Intent gates destruction**: `extend` on an existing host never re-downloads agents, never wipes the corrosion DB, and never recreates a drifted podman bridge without `--allow-replace`. Suppressed actions surface on `plan.Skipped` with a reason. `upgrade` never touches WG / podman / firewall / schema.
- **Private key never leaves host**: WG private key generated on remote via `wg genkey`; config written using `$PRIVKEY=$(cat /etc/wireguard/privatekey)` shell expansion.
- **Atomic config writes**: write to `.conf.tmp`, `mv` to `.conf`.
- **Non-disruptive WG reload**: service-restart uses `systemctl restart wg-quick@wg0 || wg syncconf wg0 <(wg-quick strip wg0)` — the fallback updates peers in kernel without tearing the tunnel.
- **Stable subnet assignment**: existing valid assignments are preserved across re-runs; adding a host never shifts existing `(namespace, host)` `/24`s. Only invalid (out-of-pool, wrong prefix, duplicate, network/broadcast IP) trigger reassignment with a warning.
- **Firewall reinstall is content-hashed**: `coolify-mesh-fw.service` is only rewritten when its expected unit text differs from the on-host sha256, so noisy restarts don't happen on converged re-runs.
### Future control plane (v5 / coold)
`coolify init` owns **fleet provisioning**: first-time bootstrap, adding hosts, and bumping agent versions — each via its own intent-scoped subcommand. Day-to-day container/firewall ops are the v5 control plane's job. See `CONTROL_PLANE.md` for the full spec, including:
- coold per-host agent (REST API on wg0, bind-mounts `/run/podman/podman.sock`, NEVER exposes socket on TCP).
- Service discovery via embedded DNS in coold + Corrosion-replicated sqlite (no env injection, no container restart on backend movement).
- Allow-rule persistence via coold's own DB + `iptables-restore --noflush` or `nft -f` batch (NOT systemd dropins per rule — doesn't scale).
- Cross-host allow rules go on the **destination host** (where DROP would otherwise fire).
When extending `coolify init`, defer dynamic responsibilities to coold. Bootstrap stays narrow: scaffold the mesh, install runtime, prep firewall chains. `extend` and `upgrade` stay narrower still: add peers and bump binaries, nothing else. coold owns everything that changes at runtime.
### Testing init
Tests live in `internal/wireguard/*_test.go` and `cmd/init/*_test.go`:
```bash
go test ./internal/wireguard/... ./cmd/init/... -v
```
Use the SSH `Runner` interface for mocking — never open real SSH connections in unit tests. `internal/ssh/fanout.go` is generic; reuse for any per-server fanout.
## `coolify firewall` — cross-host allow-rule client (alpha, v5)
**This subcommand is the second outlier** (alongside `coolify init`): it does NOT talk to the Coolify API. It is a thin REST client of the **coold** per-host agent installed by `coolify init` (coold install is unconditional as of v1.6.3). `allow` / `revoke` / `list` all go through coold's REST API (`/api/v1/firewall/allow`). `containers` stays SSH+podman because coold has no container surface yet. Transport is **SSH-bounce**: the laptop running the CLI is not a mesh peer, so it SSHes into the target host and the shell there runs `curl "http://$(wg0-mgmt-ip):8443/api/v1/firewall/..."` against coold on localhost.
coold owns all kernel-rule + persistence logic (iptables/nft backend detection, `/etc/coolify/allow.rules` snapshot, `coolify-mesh-allow.service`). The CLI never writes iptables or systemd units directly.
### What it does
- Discovers containers on the selected namespace's `coolify-<ns>-mesh` bridge (default `coolify-default-mesh`) across all listed hosts (SSH + `podman ps`). `--all-namespaces` fans out across every managed namespace.
- `POST /api/v1/firewall/allow` / `DELETE /api/v1/firewall/allow/{id}` / `GET /api/v1/firewall/allow` against coold on the host that **owns the destination IP** (per `CONTROL_PLANE.md §3`: rules go on dst host).
- Per-host bearer tokens fetched on demand from `/etc/coolify/api-token` (see `EnsureCooldAPITokenCommand` in `internal/services/coold.go` — each host generates its own random 32-byte hex token at install time).
- Idempotent at the coold level: POST of an identical tuple returns the existing id; DELETE of an unknown id returns 204.
### Subcommands
```bash
coolify firewall containers [--namespace <ns>] [--all-namespaces] # discover containers on coolify-<ns>-mesh (SSH+podman)
coolify firewall list [--namespace <ns>] [--all-namespaces] # GET /allow on every host and merge
coolify firewall allow --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]
coolify firewall revoke --namespace <ns> --from <ref> --to <ref> [--port N] [--proto tcp|udp] [--bidirectional]
```
`<ref>` accepts: container name (unique across mesh), `host:name`, short 12-char podman ID, or raw IP.
### Flags
Persistent (inherited from `cmd/common/sshmesh.go` — shared with `coolify init`):
| Flag | Default | Purpose |
|---|---|---|
| `--servers` | required | comma-separated SSH IPs |
| `--ssh-key` | required | SSH private key path |
| `--ssh-passphrase-prompt` | false | prompt for passphrase (also `COOLIFY_SSH_PASSPHRASE` env) |
| `--ssh-user` | `root` | SSH user |
| `--ssh-port` | `22` | SSH port |
| `--concurrency` | `10` | parallel SSH connections |
| `--ssh-timeout` | `30s` | SSH connect timeout |
Firewall-specific persistent:
| Flag | Default | Purpose |
|---|---|---|
| `--namespace` | `default` | mesh namespace the command operates on. Derives podman network `coolify-<ns>-mesh` for container discovery and is sent to coold as part of every rule payload / list query |
| `--all-namespaces` | false | applies to `list` + `containers` only — fans out across every namespace the mesh carries (`allow` / `revoke` still require a specific `--namespace`) |
| `--coold-port` | `8443` | TCP port coold's REST API listens on (wg0 mgmt IP). Must match `COOLD_API_BIND` emitted by `internal/services/coold.go` |
| `--coold-token` | `""` | **optional** bearer-token override (also reads `COOLIFY_COOLD_TOKEN` env). When empty (the default), the CLI SSHes each host and reads `/etc/coolify/api-token` — tokens are per-host, not centrally shared |
Allow/revoke local:
| Flag | Default | Purpose |
|---|---|---|
| `--from` | required | source container ref or raw IP |
| `--to` | required | destination container ref or raw IP |
| `--port` | `0` | dst port (0 = any) |
| `--proto` | `tcp` | `tcp`, `udp`, or `""` (any — requires `--port=0`) |
| `--bidirectional` | false | also install reverse rule on src host (needed for server-initiated flows; conntrack ESTABLISHED handles client-initiated replies) |
### Rule identity
`cid = sha256(namespace|src|dst|proto|port)[:12]`. Namespace defaults to `"default"` on the wire when empty so legacy coold peers keep working. coold computes the cid server-side on POST and returns it in the body; the CLI surfaces it as the user-facing rule ID in `firewall list` output and uses it for DELETE. Stable across calls: `revoke --namespace … --from … --to …` rebuilds the same cid and matches. Identical src/dst/proto/port tuples in different namespaces produce different cids and are managed independently.
### SSH-bounce transport
Every coold call is wrapped in a single SSH command that first discovers the host's own wg0 mgmt IP and then curls coold on localhost:
```sh
# emitted for POST / DELETE (hard-fails if wg0 missing — no coold means nothing to apply to)
MGMT=$(ip -4 -o addr show wg0 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
test -n "$MGMT" || { echo "coold mgmt IP (wg0) not found on $(hostname)" >&2; exit 1; }
curl -fsS --max-time 10 \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-X POST -d '{"src":"...","dst":"...","proto":"tcp","port":80}' \
"http://$MGMT:8443/api/v1/firewall/allow"
```
`list` uses the **soft** variant: missing wg0 emits `[]` and exits 0 so a partially-deployed mesh doesn't abort the whole fanout.
### Per-host token resolution
`cmd/firewall/helpers.go::tokenResolver` hands out tokens per host with a sync.Mutex-guarded cache:
- `--coold-token` (or `COOLIFY_COOLD_TOKEN` env) set → closure returns the override for every host; no SSH fetch.
- Otherwise → first access per host SSHes `cat /etc/coolify/api-token`, caches the result for the rest of the run. Token-fetch failures surface as a `ServerResult.Err` on the owning host (won't poison others).
The cache is scoped to one CLI invocation — no on-disk caching.
### Persistence across reboots
**coold owns this now.** On every API mutate, coold regenerates `/etc/coolify/allow.rules` (flat `iptables-save` fragment) and the companion `coolify-mesh-allow.service` restores it on boot via `iptables-restore --noflush`. Pre-coold persistence scaffolding was removed from the CLI when it migrated to REST — same file format, different writer.
### Code layout
- `cmd/common/sshmesh.go` — shared SSH/mesh flag struct `SSHMeshFlags` (+ `BindSSHMeshFlags`, `BuildSSHClient`, `ParseSSHTimeout`, `ResolvePassphrase`, `Validate`).
- `cmd/common/meshnet.go` — shared namespace plumbing: `MeshNetFlags` (namespaces + container pool/prefix), `BindMeshNetMultiFlags` (init: many), `BindMeshNetSingleFlags` (firewall: one), `PodmanNetworkFor(ns)`, `ValidateNamespaces` / `ValidateNamespace`.
- `cmd/firewall/` — Cobra layer.
- `firewall.go``NewFirewallCommand()` parent + subcommand registration.
- `flags.go``FirewallFlags` embeds `common.SSHMeshFlags` + `Namespace` + `AllNamespaces` + `CooldToken` + `CooldPort` + `WGInterface`. `PodmanNetworkName()` derives the bridge name from `Namespace`. `ResolveCooldToken()` returns the override or `""` (meaning "fetch per host").
- `allow.go``allowRevokeFlags`, `emitAllowRevoke` (discover → resolve → build rule with namespace → coold POST/DELETE per rule, resolving token per host).
- `list.go``emitList` fans out `CooldList` via `CooldListAll`, forwarding the namespace query param (or omitting it under `--all-namespaces`).
- `containers.go``containers` subcommand (still SSH+podman). Without `--all-namespaces`: single bridge. With `--all-namespaces`: SSH per host for `podman network ls --filter label=io.coolify.managed=true`, then per-namespace fanout.
- `resolve.go``resolveEndpoint(ref, []Container)` (name / host:name / short-id / raw IP).
- `helpers.go``discoverAllViaPkg`, `discoverAcrossNamespaces`, `discoverNamespacesOnHosts`, `tokenResolver` (per-host cached bearer-token closure).
- `internal/firewall/` — REST client + discovery.
- `coold_client.go``FetchCooldToken`, `CooldApply`, `CooldRevoke`, `CooldList(… , namespace)`, `CooldListAll(… , namespace)`. `buildCurlAllow/Revoke/List`, `shellSingleQuote`, `mgmtIPScript` / `mgmtIPScriptSoft`. `cooldRulePayload` carries `namespace` (required on wire; empty normalized to `"default"`).
- `discover.go``Container` (with `Namespace`), `discoverScript`, `DiscoverContainers(… , namespace, network)`, `DiscoverAll`, `DiscoverAllNamespaces` (fan-out over a `networkFor(ns)` mapper).
- `rule.go``AllowRule` (with `Namespace`), `ComputeID(namespace, src, dst, proto, port)`.
- `internal/models/firewall.go` — table/JSON row types (`ContainerRow`, `AllowRuleRow`) both now carry a `Namespace` column.
- `internal/services/coold.go``EnsureCooldAPITokenCommand` (installer writes `/etc/coolify/api-token`, mode 0600), `CooldServiceUnit` emits `COOLD_API_BIND=<mgmt-ip>:8443` + `COOLD_API_TOKEN_FILE=/etc/coolify/api-token` + `COOLD_NAMESPACES=<ns>:<network>:<gateway-ip>,…`.
### Key invariants
- **Destination-host ownership**: every rule lives on exactly one host — the one whose `/24` contains the destination IP. `--bidirectional` adds the reverse rule on the src host.
- **coold is the only kernel writer**: the CLI never runs `iptables` or touches `/etc/coolify/allow.rules` directly. Everything flows through coold's REST API.
- **Per-host tokens by default**: each coold generates its own random token at install. `--coold-token` is an escape hatch for homogeneous test / CI environments, not the common path.
- **Bidirectional is opt-in**: conntrack ESTABLISHED accept (installed by `coolify-mesh-fw.service`) handles reply packets for client-initiated flows. Only set `--bidirectional` for protocols that actually open new connections in both directions.
- **Rule identity is hash, not UUID**: coold computes it server-side so CLI and any future writer agree on the same id for the same tuple.
- **Namespace is part of identity**: `cid = sha256(namespace|src|dst|proto|port)[:12]`. Same tuple in two namespaces = two distinct rules. Empty-string namespace normalizes to `"default"` on the wire so legacy coold peers keep working.
- **Transient token exposure on remote `/proc`**: `curl -H "Authorization: Bearer $TOKEN"` is visible in `/proc/<curl-pid>/cmdline` for the ~ms lifetime of the call, root-only. Acceptable for alpha; TLS + stdin-fed tokens are a follow-up.
### Testing firewall
```bash
go test ./internal/firewall/... ./cmd/firewall/... ./cmd/common/... -v
```
Uses `fakeCooldRunner` / `cmdFakeRunner` pattern (substring → canned stdout map) — same as `cmd/init/plan_test.go`. All SSH calls mocked at the `ssh.Runner` boundary; no real SSH in unit tests. Token-fetch, mgmt-IP script, curl shape, JSON payload, and error propagation are all covered.
### End-to-end flow (verified on real hosts)
After `coolify init bootstrap --servers A,B --namespaces default,alpha ...` ran (coold must be up):
1. Baseline cross-host traffic DROPped by `COOLIFY-INTRA` in every namespace.
2. `coolify firewall containers --servers A,B --ssh-key KEY --all-namespaces` → discovery table columned by namespace.
3. `coolify firewall allow --servers A,B --ssh-key KEY --namespace default --from client --to web --port 80` → CLI SSH-fetches each host's token, POSTs to coold (body includes `"namespace":"default"`), traffic flows in the `default` namespace only.
4. Same tuple with `--namespace alpha` → separate cid, separate rule; doesn't affect `default`.
5. `coolify firewall list --servers A,B --ssh-key KEY --all-namespaces` → merged rules across every namespace on every host with their coold-assigned `cid:…` IDs.
6. `coolify firewall revoke --namespace <ns> …` → coold DELETE, rule gone, traffic DROPped again.
7. Reboot → `coolify-mesh-allow.service` (installed by coold) restores from `/etc/coolify/allow.rules`.
Add `--coold-token <hex>` only when every host was bootstrapped with the same token (CI fixtures, homogeneous test clusters).
## 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! 🚀
-759
View File
@@ -1,759 +0,0 @@
# Coolify v5 Control Plane — Server Management Spec
This document lists everything the Coolify v5 control plane must implement on top of the host provisioning performed by the `coolify init` subcommand tree (`bootstrap` for first install, `extend` for adding hosts, `upgrade` for bumping agent versions) to fully manage a fleet of mesh-connected hosts.
## Architecture overview
```
┌─────────────────────────────────────┐
│ Coolify central UI / API │
│ - Multi-tenant (cloud) or 1-tenant │
│ (self-hosted); same binary │
│ - WSS / gRPC bidi stream listener │
│ on :443 (public) │
│ - Routes commands by host_id │
└────────────────────▲────────────────┘
│ outbound TLS :443 (WSS / gRPC bidi)
│ long-lived, resumable, jittered reconnect
│ per-host JWT (issued at enroll)
┌─────────────────┴──────────────────┐
│ (per-customer gateway, │
│ OPTIONAL — one mesh host │
│ proxies N coolds → 1 stream) │
└─────────────────▲──────────────────┘
│ same stream protocol, over wg0
┌────────────────────┴────────────────┐ ┌─────────────────────────┐
│ coold (per-host agent) │ │ /run/podman/podman.sock│
│ - Dials central (or gateway) out │──┤ bind-mount, host-only │
│ - Local REST on wg0 :8443 │ │ (NEVER on network) │
│ (intra-mesh callers: CLI, peers) │ └─────────────┬───────────┘
│ - Bearer-token authn (both paths) │ │
│ - Talks ONLY to local podman sock │ ▼
└─────────────────────────────────────┘ ┌─────────────────────────────┐
│ podmand (containers, nets) │
└─────────────────────────────┘
```
**Key principles**:
1. **`/run/podman/podman.sock` is never exposed on TCP.** coold bind-mounts it and proxies a curated API. Central Coolify never touches the raw podman socket directly.
2. **coold always dials outbound — never accepts inbound from central or public internet.** One topology for self-hosted and cloud SaaS. Works through any NAT/corp firewall, scales to thousands of hosts per central region (10k+ idle streams are cheap). No "add central to every customer's wg0" — central never joins any mesh.
3. **coold still exposes a local REST API on wg0 mgmt IP** for intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway if deployed). Never reachable from public internet; wg0 is the only L3 boundary that can hit it.
4. **Per-customer gateway (optional)**: for large customers, one host in the mesh runs a stream aggregator that dials central once and proxies commands to the other coolds over wg0. Reduces stream fan-out at central from N-per-customer to 1-per-customer; adds one hop of latency. Transparent to both ends — same protocol each side.
## What `coolify init bootstrap` already provides
| Layer | Component | State |
|---|---|---|
| L3 mesh | WireGuard `wg0` per host with mgmt `/32` from `--wg-mgmt-pool` (default `100.64.0.0/16`) | Installed, configured, active |
| L3 mesh | Peer `AllowedIPs = <peer-mgmt>/32, <peer-container>/24` | Configured |
| Container runtime | Podman (distro apt) | Installed |
| Container runtime | `podman.socket` (rootful, `/run/podman/podman.sock`) | Enabled, active |
| Container network | `coolify-mesh` bridge per host with `/24` from `--container-pool` (default `10.210.0.0/16`), gateway `.1` | Created |
| Routing | `net.ipv4.ip_forward=1` (persisted via `/etc/sysctl.d/99-coolify-mesh.conf`) | Enabled |
| Firewall (mode A — `--podman` only) | `coolify-mesh-fw.service` with FORWARD ACCEPT for container subnet + POSTROUTING RETURN to skip podman MASQUERADE on wg0 | Active |
| Firewall (mode B — `--default-deny`) | `COOLIFY-INTRA` chain (ESTABLISHED/RELATED accept → COOLIFY-ALLOW → DROP), FORWARD jumps for `-s/-d <container-subnet>`, blanket ACCEPT removed | Active when set |
| Allow chain | `COOLIFY-ALLOW` (empty filter chain) | Created, ready for runtime rules |
Each host has a stable `(mgmt-ip, container-subnet)` pair. The bootstrap is idempotent — re-running `apply` only changes what drifted.
---
## What v5 control plane MUST implement
### 1. Inventory & state sync
- **Discovery**: query each host's `podman.socket` (over wg0 mgmt IP) for: containers, networks, volumes, images, system stats.
- **Drift detection**: periodically reconcile desired state (Coolify DB) against actual (podman API). Re-converge or alert.
- **Mesh join/leave**: when a host is added or removed from the cluster:
- Add → invoke `coolify init extend --servers <full list> --new-hosts <new host>` (installs the new host end-to-end, regenerates wg0 config on every existing peer with the new mgmt IP + namespace `/24`s, leaves agent binaries on existing hosts untouched).
- Remove → not supported by a first-class subcommand today. Documented workaround for alpha: tear the host out-of-band (stop services, drop it from DNS) and re-run `coolify init bootstrap` with the reduced `--servers` list on a maintenance window; a dedicated `remove-host` flow is a follow-up.
### 2. Container lifecycle
Every container op is a command sent over coold's outbound stream (central → coold) or a local REST call on coold's wg0 listener (intra-mesh → coold). coold executes the command against the local `/run/podman/podman.sock` Unix socket and streams results back.
- Create container with `--network coolify-mesh` and explicit `--ip` from the host's `/24`.
- Reserve container IPs in the control plane DB. Allocator skips `.1` (bridge gateway), reserves `.2` for coold itself, `.3-.254` for app containers.
- Start, stop, restart, remove.
- Stream logs via `/containers/{id}/logs?follow=true` (coold relays podman API frames over the open control stream).
- Health checks via `/containers/{id}/healthcheck/run`.
- Resource limits, env vars, mounts, volumes, secrets — all standard podman API surfaced through coold.
#### coold is a primitive proxy, not an app brain
coold follows the **kubelet analogue**: it knows containers, images, volumes, networks, iptables, and Corrosion writes. It does **not** know apps, compose, Dockerfiles, buildpacks, or Nixpacks. Central Coolify is the apiserver+controllers: it parses app-level config and compiles it into a sequence of primitive ops streamed to coold.
Test for "should this live in coold?": could a second orchestrator (a Nomad-style competitor) reuse this coold with a different app model? If yes → coold. If no → central.
#### Wire surface (enumerable)
Same endpoint set on both transports (outbound stream from central, local REST on wg0 for intra-mesh callers). New verbs require a coold release — there is no `/podman/raw` passthrough.
```
# Images
POST /api/v1/images/pull {ref, auth?} -> {digest}
GET /api/v1/images -> [{ref, digest, size}]
DELETE /api/v1/images/{ref}
# Containers (filtered podman surface)
POST /api/v1/containers <create spec> -> {id}
POST /api/v1/containers/{id}/start
POST /api/v1/containers/{id}/stop {timeout?}
POST /api/v1/containers/{id}/restart
DELETE /api/v1/containers/{id} {force?}
GET /api/v1/containers/{id} (inspect)
GET /api/v1/containers/{id}/logs?follow=true (streamed)
POST /api/v1/containers/{id}/exec {cmd, tty?} (streamed)
POST /api/v1/containers/{id}/healthcheck/run
# Volumes
POST /api/v1/volumes {name, driver, labels}
DELETE /api/v1/volumes/{name}
GET /api/v1/volumes/{name}
# Networks (bootstrap creates coolify-mesh; extra per-app nets created here)
POST /api/v1/networks {name, driver, options, labels}
DELETE /api/v1/networks/{name}
GET /api/v1/networks
# Firewall (coold = sole writer)
POST /api/v1/firewall/allow {src, dst, proto?, port?} -> {id}
DELETE /api/v1/firewall/allow/{id}
GET /api/v1/firewall/allow
# Service endpoints (Corrosion writer; used by central to register deploys)
POST /api/v1/services/register
DELETE /api/v1/services/{id}/endpoints/{container_id}
GET /api/v1/services/{id}/endpoints
# DNS (diagnostics)
GET /api/v1/dns/lookup/{name}
GET /api/v1/dns/stats
# Host facts (read-only; central scrapes these for observability + scheduling)
GET /api/v1/host/info (podman info, kernel, wg state, load)
GET /api/v1/host/containers (podman ps -a)
GET /api/v1/host/stats (podman stats snapshot)
```
**Deny filter on `POST /containers`** (defense-in-depth even though central is trusted):
- Block `--privileged`, `--cap-add=SYS_ADMIN/NET_ADMIN` unless host is marked `allow_privileged=true`.
- Block host-path bind mounts outside a configurable allowlist (default: none).
- Block host netns (`--net=host`) unless the container is coold itself.
Anything not above is not coold's job. No `/apps`, `/deployments`, `/compose`, `/build`, `/podman/raw`. coold does not parse compose, Dockerfiles, buildpacks, or any app-level config — central compiles these into sequences of the primitive ops above and streams them down.
#### Networks
Default = shared `coolify-mesh` bridge. Containers get `.coolify.internal` DNS + flat L3 across the mesh. Users may define extra podman networks per app (docker-compose `networks:` style) via `POST /networks` + container attach on create. Central compiles compose into network-create + container-attach primitives.
#### coold deployment
coold runs as a privileged container on each host (or as a host systemd service). `coolify init bootstrap` puts it in place at install time (and `coolify init upgrade` bumps its version later): binary, systemd unit with `COOLD_API_BIND=<wg0-mgmt-ip>:8443`, random per-host bearer token at `/etc/coolify/api-token` (mode 0600), outbound stream config written atomically to `/etc/coolify/coold.env`.
Reference container spec (equivalent to systemd-service deployment):
```bash
podman run -d --name coold --restart=always \
--network coolify-mesh --ip 10.210.X.2 \
-v /run/podman/podman.sock:/run/podman/podman.sock \
-v /etc/coolify/coold:/etc/coolify/coold:ro \
--security-opt label=disable \
-p 100.64.0.X:8443:8443 \
ghcr.io/coollabs/coold:latest
```
- **Outbound stream**: coold dials `wss://<central-host>/v1/agent` (or gRPC bidi) on start, presenting its per-host JWT. Central routes commands to it by host id over the open stream. Stream is the primary control channel for both self-hosted and cloud SaaS — same code path, same binary.
- **Local REST on wg0 mgmt IP (`100.64.0.X:8443`)**: accepts intra-mesh callers only (the `coolify firewall` CLI via SSH-bounce, other coolds in the same mesh, a per-customer gateway). Not reachable from public internet — wg0 is the L3 boundary. Bearer-token auth on every request.
- **No inbound from central**: central never dials coold. All mutations arrive over the coold-initiated stream; no `COOLIFY-ALLOW` rule for "central → host:8443" needed. Works through NAT/corp firewalls.
#### Control channel transport (stream)
Two candidates; spec-time decision, not per-host:
| Option | Pros | Cons |
|---|---|---|
| **gRPC bidi stream over HTTP/2** *(chosen)* | typed Protobuf schemas, native server-streaming for logs/exec, versionable wire | stricter proxy requirements (some corp proxies still mangle HTTP/2); larger runtime |
| WebSocket (WSS over :443) *(fallback)* | traverses every proxy, tiny overhead, libs everywhere | framing is custom-on-top; manual request/response correlation |
**Decision: gRPC bidi + Protobuf.** Typed schemas + native server-streaming for logs and exec outweigh the proxy risk; WSS remains the documented fallback if gRPC-through-proxy issues show up in the field. Both run on :443, so customer-side egress rules stay unchanged either way.
#### Enrollment
coold registers once at install using a one-time token from central:
```bash
coolify init bootstrap \
--central-url https://cloud.coolify.io \
--enroll-token <one-time-hex>
```
1. coold POSTs `(host_id, wg0_mgmt_ip, container_subnet, enroll_token)` to `https://<central>/v1/enroll`.
2. Central validates the enroll token (scoped to a tenant, single-use, short TTL) and issues a long-lived per-host JWT + TLS-pinned central cert. Response stored in `/etc/coolify/coold.env` (mode 0600).
3. coold burns the enroll token and switches to JWT for the persistent stream.
4. Central revokes by invalidating the JWT in its own DB; next stream reconnect fails auth and the host is quarantined until re-enrolled.
#### Reconnect + fleet-restart storms
Single-central-restart would otherwise trigger simultaneous reconnects from every host. Mitigations:
- **Jittered backoff**: exponential from 1s up to 60s with full jitter. 10k hosts reconnecting spread across ~minutes, not seconds.
- **Resumable streams**: stream carries a monotonic `last_seq` per host so central can replay missed commands after reconnect without central-side queueing beyond an in-memory ring buffer.
- **Region sharding**: DNS round-robin or geo-steering across multiple central stream gateways; each gateway holds O(10k) streams. Stateful routing via consistent-hashing on host_id so a host lands on the same gateway across reconnects (cache affinity).
#### Per-customer gateway (optional)
For customers with 50+ hosts, one designated mesh host runs a **gateway mode coold** (same binary, different role):
- Dials central like any other coold.
- Accepts incoming streams from its peer coolds over wg0 (they dial `wss://<gateway-mgmt-ip>:8443/v1/agent-peer` instead of central).
- Relays commands down, responses up. Maintains O(hosts-in-mesh) inbound streams + 1 outbound to central.
Saves N-1 WAN streams at central per customer; costs one hop of latency + one more thing to keep alive. Opt-in via `coolify init bootstrap --gateway-for-mesh` on the chosen host; peers get `--via-gateway <gateway-mgmt-ip>` at install.
### 3. Network policy (firewall)
When host has `--default-deny` enabled, **all cross-host container traffic is dropped by default**. The control plane decides who talks to whom.
#### Division of labour: bootstrap vs coold vs central
| Layer | Owner | Responsibility |
|---|---|---|
| Chain scaffold (COOLIFY-INTRA, COOLIFY-ALLOW, FORWARD jumps, conntrack early-accept, POSTROUTING RETURN) | `coolify init bootstrap` (also reconverges on `extend`) | Install + idempotently re-converge on flag change. Never touches individual allow rules. |
| Rule metadata (who/when/why, audit log, RBAC, tenant scoping, app→rule mapping) | **Coolify central DB** | Authoritative store. All rich queries, audit trails, and access control live here. |
| Raw rule tuples `(src, dst, proto, port)` on the host | **coold** (single writer) | Apply to kernel + snapshot to `/etc/coolify/allow.rules` for reboot. Stateless-ish — just a cache of what the caller (central Coolify or `coolify firewall` CLI) told it to apply. No metadata, no DB. |
**Key split**: central Coolify owns rich state (metadata, audit, RBAC). Per-host coold owns only the raw rules needed to program the kernel + survive reboot. This keeps coold small and lets a single central DB be the source of truth for all cross-cutting concerns.
**App-topology compilation happens in central.** coold applies the rule tuples it is told to apply; it does not generate rules from app intent (e.g. "allow service `web``db`"). Central compiles that from the app model and sends individual `POST /firewall/allow` frames.
**`coolify init` is intentionally not the rule store.** Bootstrap creates the empty allow chain. coold is the sole writer into it. Callers reach coold via two paths: (a) central Coolify over the coold-initiated outbound stream, (b) intra-mesh callers (`coolify firewall` CLI via SSH-bounce, other coolds, optional per-customer gateway) via coold's local REST API on wg0 mgmt IP.
#### Reboot persistence
Works the same pre- and post-coold because both use the same file format:
- `/etc/coolify/allow.rules` — filter-table fragment, `:COOLIFY-ALLOW` + `-A COOLIFY-ALLOW` lines only. Written atomically (`.tmp` + `mv`) on every rule change.
- `/etc/systemd/system/coolify-mesh-allow.service``Type=oneshot`, `After=coolify-mesh-fw.service`, `Wants=coolify-mesh-fw.service`. `ExecStart=iptables-restore --noflush /etc/coolify/allow.rules`. `--noflush` means only `COOLIFY-ALLOW` is populated; nothing else is disturbed.
coold owns the file: it rewrites `/etc/coolify/allow.rules` on every successful API mutate, keeping it in sync with the live kernel. The `coolify firewall` CLI never touches the file — it POSTs/DELETEs through coold and coold handles persistence + systemd unit install. One writer, one format.
#### Allow-rule lifecycle
For an allow `(srcIP, dstIP)`:
- Add ACCEPT to `COOLIFY-ALLOW` on the host that **owns dstIP** (where DROP would otherwise fire).
- For bidirectional traffic (e.g. TCP, ICMP echo+reply), add the reverse `(dstIP, srcIP)` on the host that owns srcIP. (Reply packets traverse THAT host's FORWARD chain when arriving back, and dst-side check fires there.)
- **One unidirectional allow = one rule on one host. One bidirectional allow = two rules on two hosts.**
- Conntrack ESTABLISHED early-accept (installed by bootstrap) handles in-flow follow-up packets — no need to add per-packet rules.
#### Persistence + scale model
Per-rule systemd dropins do NOT scale (1000 rules × `daemon-reload` + restart = minutes, fs clutter, audit nightmare). Instead, coold is a thin rule-applier backed by central:
```
coold service (per host)
├─ Snapshot file: /etc/coolify/allow.rules (flat iptables-save fragment)
├─ Boot: systemd unit runs iptables-restore --noflush from file
├─ API mutate: apply iptables -A/-D → regen snapshot via iptables-save
└─ Reconcile: central periodically diffs its DB vs coold's live
`iptables -S COOLIFY-ALLOW`; pushes deltas to re-converge
```
Source of truth for **the set of rules that should exist** = central Coolify DB. Source of truth for **what's programmed in the kernel right now** = kernel itself, mirrored to `/etc/coolify/allow.rules` for reboot. coold does not keep its own DB.
#### Write ordering (crash/reboot safety)
Every mutating call from central → coold follows this sequence:
1. **Central writes to its own DB first** (with its own audit/tenant metadata). Durable with the rest of Coolify's state.
2. **Central sends command over the open stream** to coold with just `(src, dst, proto, port)`. No inbound connection to coold — the stream was already established by coold at boot.
3. **coold applies `iptables -A/-D`** to kernel.
4. **coold regenerates `/etc/coolify/allow.rules`** via `iptables-save` (atomic `.tmp` + `mv`).
5. **coold returns success to central** over the same stream (response carries the request id).
6. **On any failure in 35**, central marks the row "pending" in its DB and retries / surfaces to operator. Nothing is lost because step 1 is already durable.
Consequences:
- **Crash between steps 3 and 4** → kernel has the rule, file doesn't. Reboot loses the rule. Central's reconcile loop detects divergence (its DB has the rule, live kernel doesn't after boot) and re-pushes. Safe, with a small drift window bounded by reconcile cadence.
- **Crash between steps 4 and 5** → kernel + file both updated, but central didn't get the ack. Central retries; `iptables -C` guard makes the retry a no-op. Safe.
- **coold down when central wants to mutate** → central queues the change and retries on reconnect. No state loss on either side.
- **Central DB is authoritative** — a reboot can only *shrink* the live rule set compared to central's view, never grow it.
Bulk ops (`/bulk`) ship the whole batch in one REST call. coold applies via `iptables-restore --noflush` / `nft -f` (atomic transaction), then regens snapshot once.
Apply paths:
| Backend | Bulk apply (1000 rules) | Atomicity |
|---|---|---|
| `iptables -A` per rule | ~5s | per-rule |
| `iptables-restore --noflush` (preferred for iptables-legacy) | ~50ms | per-batch |
| `nft -f /tmp/rules.nft` (preferred when host uses nftables backend) | ~10ms | atomic transaction |
coold detects backend (`iptables --version` or presence of nftables socket) and picks. Bootstrap doesn't care.
For **systemctl restart coolify-mesh-fw.service** (e.g. a `coolify init bootstrap` re-run after a flag flip, or `coolify init extend` reinstalling the unit because the namespace list changed): the unit flushes COOLIFY-INTRA but **never flushes COOLIFY-ALLOW** — existing rules survive. If somehow lost (manual `iptables -F COOLIFY-ALLOW`, crash mid-write), central's reconcile loop compares its own DB against `iptables -S COOLIFY-ALLOW` from each host and re-pushes any missing tuples within the reconcile interval.
#### Allow API surface
Same method/path set is served on both transports — stream (central → coold) and local REST (intra-mesh → coold). Stream = JSON-RPC frames carrying the same `(method, path, body)` tuple; REST = plain HTTP on wg0 mgmt IP :8443.
```
POST /api/v1/firewall/allow {src, dst, proto?, port?, comment?} → returns id
DELETE /api/v1/firewall/allow/{id}
GET /api/v1/firewall/allow list
GET /api/v1/firewall/allow/{id} show + match counters
POST /api/v1/firewall/allow/bulk {add: [...], remove: [...]} atomic batch
POST /api/v1/firewall/reconcile force full reload
```
coold translates each row into the right iptables/nft fragment. Per-port: `-p tcp --dport <N>`. Source/dest IP, CIDR, or set reference (for grouping like "all-frontend-ips").
For very large rule sets: use **nftables sets** so a rule references a set name, and the set membership changes are O(1):
```
nft add element ip filter coolify_allowed_pairs { 10.210.0.10 . 10.210.1.10 }
```
One static rule like `ct state new ip saddr . ip daddr @coolify_allowed_pairs accept` evaluates in O(log n) regardless of set size. coold maintains the set rather than thousands of rules. Optional optimization for v5+.
#### Intra-host isolation (NOT enforced by `--default-deny`)
Linux + netavark + Ubuntu 24.04: bridge L2 traffic bypasses iptables FORWARD even with `bridge-nf-call-iptables=1`. **Containers on the same host's `coolify-mesh` bridge can always reach each other.**
Two paths for v5 to enforce intra-host isolation:
- **(Recommended) Per-app podman networks**: each Coolify service = own podman network with `--opt isolate=true`. Different networks can't talk by default; use `podman network connect` for cross-app.
- Trade-off: each network needs its own `/24` from container pool → wastes pool. Or carve `/27`s (allocator extension needed).
- **(Alternative) ebtables L2 filter**: `ebtables --logical-in podman1 --logical-out podman1 --ip-src X --ip-dst Y -j ACCEPT/DROP`. Independent toolchain, separate persistence. Bridge name discovery needed.
v1 ships without intra-host enforcement. v5 picks one path.
### 4. Container IP allocation per host
The bootstrap gives each host a `/24` (e.g. `10.210.0.0/24`). The control plane:
- Reserves `.1` (bridge gateway, skip).
- Allocates `.2-.254` for containers, deduplicated against running `podman ps` IPs.
- Pins IP via `podman run --ip <IP>` so DNS/firewall rules stay stable.
- Detects exhaustion early; alerts user to grow `--container-pool` or `--container-prefix`.
For `/24` per host: 253 containers max. For higher density: re-bootstrap with `--container-prefix 23` or larger pool.
### 5. Service discovery
**Pattern**: embedded DNS server in coold, backed by [Corrosion](https://github.com/superfly/corrosion) (CRDT sqlite gossiped via SWIM across the mesh). No env injection. No container restarts on backend movement.
#### Why DNS-via-coold over alternatives
| Approach | Stable target? | Backend move = restart? | Complexity |
|---|---|---|---|
| Env injection (`DB_HOST=10.210.5.42`) | no — IP changes | yes (rolling redeploy on every change) | medium (template engine + dep graph) |
| **Embedded DNS in coold** | **yes (hostname)** | **no** | **low (~200 LoC)** |
| VIP per service | yes (IP) | no | high (keepalived/BGP/IPVS) |
| Per-host HTTP/TCP proxy | yes (port) | no | medium (proxy config) |
DNS chosen: smallest moving parts, works for any protocol, standard `getaddrinfo()` path, ubiquitous client support.
#### Corrosion schema (replicated sqlite)
```sql
CREATE TABLE services (
id TEXT PRIMARY KEY, -- "myapp.db"
coolify_app_id TEXT NOT NULL,
name TEXT NOT NULL, -- "db"
namespace TEXT NOT NULL, -- "myapp"
port INTEGER, -- canonical port (informational)
updated_at INTEGER NOT NULL -- ms epoch (CRDT clock)
);
CREATE TABLE service_endpoints (
service_id TEXT NOT NULL,
container_id TEXT NOT NULL,
host_mgmt_ip TEXT NOT NULL, -- 100.64.0.X (host running the container)
container_ip TEXT NOT NULL, -- 10.210.X.Y
healthy INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (service_id, container_id)
);
```
Each coold writes its own host's container facts. Reads are local sqlite (sub-ms). Gossip handles distribution; convergence ~1s in small clusters.
#### Embedded DNS server
```go
// pseudocode — ~200 LoC total
func (c *Coold) serveDNS() {
pc, _ := net.ListenPacket("udp", "10.210.X.1:53") // bridge gateway IP
for {
buf := make([]byte, 512)
n, addr, _ := pc.ReadFrom(buf)
go c.handle(buf[:n], addr, pc)
}
}
func (c *Coold) handle(query []byte, src net.Addr, pc net.PacketConn) {
msg := dns.Unpack(query)
name := msg.Questions[0].Name // "myapp.db.coolify.internal."
if !strings.HasSuffix(name, ".coolify.internal.") {
// Forward to upstream (configurable; default 1.1.1.1).
pc.WriteTo(c.upstream.Query(msg), src)
return
}
serviceID := strings.TrimSuffix(name, ".coolify.internal.")
var ips []string
c.corrosion.Query(`
SELECT container_ip FROM service_endpoints
WHERE service_id = ? AND healthy = 1
`, serviceID).Scan(&ips)
if len(ips) == 0 {
pc.WriteTo(dns.NXDOMAIN(msg), src); return
}
pc.WriteTo(dns.AnswerA(msg, ips, ttl=5), src)
}
```
Listens on **bridge gateway IP** (`10.210.X.1:53`) of the host's `coolify-mesh` bridge — reachable from every container in the host's `/24` via standard kernel routing.
#### Container creation hook
Every container coold creates gets:
```
podman run --dns 10.210.X.1 --dns-search coolify.internal ...
```
App code uses short names: `getaddrinfo("myapp.db", ...)` → libc appends search suffix → `myapp.db.coolify.internal` → coold answers from local Corrosion.
#### Resolution flow
```
1. App in container A on host-1 (10.210.0.10) calls getaddrinfo("myapp.db")
2. libc reads /etc/resolv.conf:
nameserver 10.210.0.1
search coolify.internal
3. UDP query "myapp.db.coolify.internal" → 10.210.0.1:53
4. coold@host-1 reads local Corrosion → 10.210.5.42 (running on host-3)
5. Reply: A 10.210.5.42, TTL=5
6. App opens TCP to 10.210.5.42:5432
7. Routed via wg0 (peer host-3's AllowedIPs covers 10.210.5.0/24)
→ bridge → container
8. (If --default-deny is on, COOLIFY-ALLOW on host-3 must permit
10.210.0.10 → 10.210.5.42.)
```
#### Backend movement (zero restart on dependents)
```
T+0: myapp.db @ 10.210.5.42 on host-3. Endpoint row gossiped.
T+10s: User redeploys myapp.db on host-3.
coold@host-3:
- new container at 10.210.5.43
- INSERT new endpoint row (10.210.5.43)
- DELETE old endpoint row (10.210.5.42)
- kill old container
Corrosion gossips delta.
T+11s: All hosts have updated state.
T+15s: App on host-1 has stale TCP to 10.210.5.42 — broken when old container died.
App's reconnect logic re-resolves myapp.db → 10.210.5.43 → reconnects.
App container NEVER restarted, env NEVER changed.
```
App must have reconnect logic (every reasonable DB/cache client does). DNS provides the new IP transparently.
#### TTL
5s. Trade-off:
- Lower = faster failover, more queries.
- Higher = quieter DNS, slower failover.
Apps with infinite-cache resolvers (Java's `networkaddress.cache.ttl=-1`) won't see updates. Document for users; not coold's problem.
#### Multi-replica services
Resolver returns ALL healthy A records. Apps with proper conn pools (postgres, redis clients) handle multi-target naturally. No client-side LB protocol needed.
#### Health & staleness
- coold marks `healthy=0` on healthcheck fail. DNS stops returning that IP within next query.
- Stale-row TTL: rows older than 60s without heartbeat are pruned (owning coold heartbeats every 15s).
#### TLD
`.coolify.internal``.internal` is RFC 6761 reserved for private use. Won't collide with public TLDs. Configurable per-cluster.
#### Failure modes
| Failure | Behaviour |
|---|---|
| coold dies | Cluster DNS resolution stops. systemd restarts coold (~3s). Existing connections survive. Same profile as k8s losing CoreDNS. |
| Corrosion split-brain | Each partition serves local view; CRDT merges cleanly when partition heals. May serve stale IPs during partition. |
| Backend healthy in DB but unreachable | DNS returns IP → app connection fails → app retries. If multi-replica, may pick different one on retry. |
| Container has no `--dns` (created outside coold) | No cluster resolution. Document: only coold-managed containers get discovery. |
| Cross-region high latency | Slower convergence; stale DNS for 1030s. Acceptable v1. |
#### API surface
Same dual-transport model as the firewall API — stream from central, REST from intra-mesh callers.
```
POST /api/v1/services/register {service_id, app_id, name, namespace, port, container_id, container_ip, host_mgmt_ip}
DELETE /api/v1/services/{service_id}/endpoints/{container_id}
GET /api/v1/services/{service_id}/endpoints
GET /api/v1/services?namespace=myapp
GET /api/v1/dns/lookup/{name} (debug — what coold would answer)
GET /api/v1/dns/stats (qps, hit/miss/forward counts)
```
Most ops are automatic side effects of deploy/scale/health-check. Central rarely calls `/services/register` directly — coold registers on container create, deregisters on remove.
coold writes Corrosion rows on behalf of central (explicit `POST /services/register` frames); it does not infer service identity from container labels. Central supplies `service_id` explicitly so naming policy stays in one place.
#### Bootstrap impact
Minimal. `coolify init bootstrap` creates every `coolify-<ns>-mesh` Podman network with `--disable-dns` so netavark never starts aardvark-dns on the bridge gateway `:53`. coold owns that socket. Bridge gateway IP was always reserved by `MachineIP()`.
Pre-alpha deployments that created the network without `--disable-dns` are detected at plan-time (probe reads `podman network inspect .DNSEnabled`). A `recreate-podman-network` action drops and recreates the network — same subnet, same gateway, but with DNS disabled. Any attached containers are disconnected via `podman network rm -f`.
#### Port 53 conflict handling
Three layers protect coold's `10.210.X.1:53` socket:
| Layer | Mechanism | Covers |
|---|---|---|
| 1. Bootstrap | `podman network create --disable-dns` (+ drift recreate) | aardvark-dns squat |
| 2. Bind target | coold binds **bridge gateway IP only**, not `0.0.0.0` and not wg0 mgmt IP | host wildcard DNS daemons (dnsmasq/pihole on `0.0.0.0:53`) and wg0 bloat |
| 3. Preflight | `net.Listen("tcp", gateway+":53")` probe before `ListenPacket` | clear actionable error + systemd `Restart=on-failure` retry |
systemd-resolved on Ubuntu binds `127.0.0.53:53` — no conflict with bridge gateway.
Bind rule: coold DNS is container-facing only (listen on bridge gateway IP). coold REST API is operator-facing (listen on wg0 mgmt IP, port 8443). Separate concerns, separate sockets.
### 6. Ingress (public traffic → containers)
`coolify init` doesn't manage public ingress. v5 deploys a reverse proxy (Traefik/Caddy) per host or HA pair:
- Listens on host public IP `:80/:443`.
- Routes `Host: app.example.com` → container IP (over container bridge or wg0 if cross-host).
- Cert management via ACME.
- Coolify generates proxy config from app routing rules.
Important: ingress proxy needs its own podman network OR can share `coolify-mesh`. Sharing means proxy can reach all containers — fine since it's the entrypoint.
### 7. Deployment workflows
Deploy is a **central-side state machine** that compiles app intent (compose / Dockerfile / buildpack / Nixpacks / raw image) into a sequence of coold primitives (see §2 wire surface). coold does not participate in planning — it executes one primitive per frame.
#### Build pipeline (not in coold)
```
git push
Central receives webhook
Builder (BuildKit / Buildpacks / Nixpacks) ← coold NOT involved
- Self-hosted: first mesh host by default;
central may pin via target_host_id per build.
- Cloud: central-run.
Push to registry (registry.coolify.io or customer's) ← coold NOT involved
Central deploy controller → primitive op stream → coold on target host
```
coold's only role in the build path: `POST /images/pull` once the tag exists in the registry.
#### Deploy flow (T0T10 — every frame = one §2 primitive)
```
T0 Central builder clones source, invokes BuildKit / buildpack / nixpacks.
Output: OCI image @ registry.coolify.io/tenant/web:v2.
T1 Central deploy controller picks target host H (scheduler = least-loaded / pin).
T2 Frame: POST /images/pull {ref: "registry.coolify.io/tenant/web:v2"}
coold@H calls podman.sock /images/create, streams progress back.
T3 Frame: POST /volumes {name: "web-data", driver: "local"}
coold@H idempotent; no-op if exists.
T4 Frame: POST /containers (central templates from compose + resolved secrets)
body:
{
"image": "registry.coolify.io/tenant/web:v2",
"name": "web-v2-a3f91",
"network": "coolify-mesh",
"ip": "10.210.H.42",
"dns": ["10.210.H.1"],
"dns_search": ["coolify.internal"],
"env": {"DATABASE_URL": "postgres://…"},
"mounts": [{"volume": "web-data", "target": "/data"}],
"healthcheck": {"test": ["CMD","curl","-f","http://localhost/"], "interval": "5s"},
"labels": {"coolify.app": "web", "coolify.version": "v2"}
}
coold checks deny filter → calls podman.sock /containers/create → returns id.
T5 Frame: POST /containers/{id}/start
coold starts container.
T6 Central polls GET /containers/{id} or subscribes to events.
Wait for healthy; abort + rollback on timeout.
T7 Frame: POST /services/register
coold writes Corrosion row. Gossip distributes; DNS now answers new IP.
T8 Frame: POST /firewall/allow (on dst host — coold = sole kernel writer)
{src: proxy-ip, dst: 10.210.H.42, proto: "tcp", port: 80}
T9 Central ingress controller regenerates proxy config (Caddy/Traefik/nginx)
→ upstreams point to new container IP.
Frame: POST /containers/{proxy-id}/exec (reload) or proxy-specific reload.
T10 Cutover complete. Central retires the old container:
POST /containers/{old-id}/stop {timeout: 10}
DELETE /containers/{old-id}
DELETE /services/web/endpoints/{old-container-id}
DELETE /firewall/allow/{old-rule-id}
```
Every T-frame is one of the narrow primitives in §2. coold never runs compose, never builds, never picks hosts, never reads app config. If a future verb is needed, it gets added to §2 and the coold release, not smuggled through a passthrough.
**coold non-goals for deploy**: no compose parser, no buildpacks, no Dockerfile handler, no Nixpacks, no scheduler, no ingress templating, no rollback orchestration, no secrets store.
### 8. Storage & volumes
- Local podman volumes per host (`/var/lib/containers/storage/volumes`).
- Cross-host: distributed FS (out of scope) OR pin stateful services to a host (anti-affinity rules in scheduler).
- Backup: `podman volume export` + scp to backup target. Coolify orchestrates schedule.
- **v5 alpha decision**: stateful services **pin to host**. Cross-host volume movement / distributed FS is post-alpha.
### 9. Scheduling
**Placement lives in central.** coold provides facts (`GET /host/info`, `/host/stats`, `/host/containers`); central consumes them, picks the target host, and sends the resulting primitives. coold has no placement logic.
When user creates an app, central decides which host runs it:
- Round-robin / least-loaded / explicit pin.
- Pinned services (DB, persistent volumes) tracked in central DB.
- Re-schedule on host failure (wg0 down, last-handshake stale).
Failure detection: central polls `wg show wg0 latest-handshakes` via `GET /host/info` on every host, parses seconds-since-handshake; alerts if > N seconds.
### 10. Observability
coold exposes read-only `/host/*` endpoints surfacing the facts below. Central (or a central-side scraper) pulls from each host and feeds Prometheus / VictoriaMetrics. coold does **not** push metrics.
Per host metrics (over wg0 via coold endpoints):
- `GET /host/info` → podman info (version, storage driver, free space), kernel, wg state, load.
- `GET /host/containers``podman ps -a --format json` state.
- `GET /host/stats``podman stats --no-stream --format json` CPU/mem per container.
- Wg handshake + transfer bytes via `GET /host/info` (`wg show wg0 dump` internally).
- `iptables -nvL COOLIFY-ALLOW` match counters (for audit) exposed through `GET /firewall/allow` with counters.
Stream into central time-series store (Prometheus / VictoriaMetrics).
### 11. Updates
- Coolify runtime image self-updates (container restart with new image).
- WireGuard / Podman package updates: `coolify init bootstrap` re-runs idempotently and picks up newer packages from apt. Agent (coold/corrosion/scheduler/builder) bumps go through `coolify init upgrade --coold-version vX.Y.Z` etc. Schedule periodic re-apply (weekly?).
- Mesh config changes (new host, removed host) trigger re-apply on all hosts; control plane orchestrates.
### 12. Security posture
- **Private keys never leave hosts**: WG private key generated on remote, never transits SSH (already done by bootstrap).
- **Podman socket access**: `/run/podman/podman.sock` stays as a rootful Unix socket on each host — **NEVER exposed on TCP**. Only **coold** (per-host agent, see §2) has access via bind-mount. coold surfaces a curated REST API over wg0 with TLS + bearer auth. This means:
- Compromise of a non-coold container does NOT grant podman API access.
- coold enforces bearer-token authn and can deny dangerous flags (e.g. `--privileged`) at the API surface. RBAC, per-user/tenant scoping, and business audit live **only** in central Coolify (see §3 split).
- No `podman system service tcp://...` listener; no need for socket-level TLS.
- Central Coolify only knows the coold endpoint, not the underlying socket.
- **SSH access**: bootstrap uses key-based SSH. Control plane should rotate SSH keys per agent install, store in encrypted DB. After bootstrap, day-to-day ops go via coold REST — SSH is for re-bootstrap only.
- **Host firewall (iptables INPUT chain)**: bootstrap doesn't lock down INPUT. v5 should drop public access to ports other than `:51820/udp` (WG), `:22/tcp` (SSH), `:80/:443` (ingress). coold's `:8443` binds to the wg0 IP only, so it's already not on the public interface.
- **coold port reachability**: central never dials in — coold's outbound stream is the control path — so no `COOLIFY-ALLOW` rule for central is needed. coold's local REST on wg0 mgmt IP (`:8443`) is reachable only from inside the mesh, and is used by (a) the `coolify firewall` CLI via SSH-bounce, (b) other coolds in the same mesh, (c) an optional per-customer gateway. Nothing on the public internet reaches coold. Outbound TLS :443 to central must be permitted by the customer's egress firewall — standard for any SaaS agent.
- **Audit**: central Coolify is the sole authoritative audit log — who-when-why metadata for every COOLIFY-ALLOW change. coold writes only an ops/debug request log (request id, endpoint, status, duration) for troubleshooting; it never sees the identity of the human caller, only the bearer token used to reach it.
### 13. Failure modes & recovery
| Failure | Detection | Recovery |
|---|---|---|
| Host SSH unreachable | bootstrap apply error | Manual investigation; node marked unhealthy in DB |
| WG peer offline (`latest_handshake > 180s`) | `wg show` poll | Mark unhealthy; re-schedule containers if pinning permits |
| Podman socket unreachable | API call timeout | Restart `podman.socket`; if persistent, re-bootstrap |
| Firewall service failed | `systemctl is-active != active` | Re-run `coolify init bootstrap`; service is idempotent |
| Container OOM/crash | `podman events` watcher | Restart per restart policy; alert after N crashes |
| Container subnet exhausted | allocator returns error | Alert; offer apply with bigger `--container-prefix` |
| Mgmt IP exhausted | allocator returns error | Alert; rare for /16 |
| `coolify-mesh` bridge missing | probe `podman network exists` returns no | Re-run apply |
| User manually deletes COOLIFY-ALLOW chain | runtime check | Re-run apply (recreates chain via service restart) |
### 14. Multi-tenancy (deferred)
If Coolify ever supports tenant isolation:
- Tenant = own podman network namespace per host.
- Allows always scoped within tenant; cross-tenant requires explicit allow.
- Pool subdivided per tenant. Allocator extension.
Not in v1 or v5 initial.
---
## Out of scope (now and likely v5)
- Rootless containers (would need user namespace mapping, separate sockets per user).
- IPv6 mesh (`fdcc::` style, ip6tables mirror).
- Hardware-level isolation (SELinux profiles, AppArmor).
- Live migration (qemu/criu).
- Distributed storage (Ceph/Longhorn).
- macvlan / SR-IOV networking.
- Autoscaling.
- BGP / external network announcements.
---
## Quick reference — operations the agent CLI should expose
(Future `coolify-cli` subcommands beyond `init`)
```
coolify deploy <app> # build + push + run
coolify scale <app> --replicas N
coolify firewall containers --servers A,B ... # discover mesh containers (SSH+podman)
coolify firewall list --servers A,B ... # list allow rules across hosts (coold GET /allow, SSH-bounced)
coolify firewall allow --from <ref> --to <ref> --port N # add allow rule (coold POST /allow, SSH-bounced)
coolify firewall revoke --from <ref> --to <ref> --port N # remove allow rule (coold DELETE /allow/{id})
coolify host list # show mesh state, last-handshake, container count
coolify host add <ip> --ssh-key K
coolify host remove <ip>
coolify logs <container>
coolify exec <container> -- sh
```
`coolify firewall` is implemented today as a thin SSH-bounced REST client of coold (§3 above). The laptop running the CLI isn't a mesh peer, so every call SSHes into the target host and runs `curl "http://<wg0-mgmt-ip>:8443/api/v1/firewall/..."` against coold locally. Per-host bearer tokens are fetched from `/etc/coolify/api-token` on demand (with `--coold-token` as an override for homogeneous test clusters).
Everything else on the roadmap (`coolify deploy`, `coolify scale`, `coolify logs`, `coolify exec`) targets the **central** API (SaaS or self-hosted central), not coold directly. Central compiles the request into the primitive-op sequence in §7 and streams it to coold. Only `coolify firewall` currently bypasses central and hits coold directly — legacy + test harness until central wires up `/firewall/*` itself.
---
## Summary
`coolify init bootstrap` does the **first-time host install**: WG mesh, podman runtime, bridge network, default-deny scaffold, coold/corrosion/scheduler/builder agents. `coolify init extend` adds hosts to an existing mesh without disturbing converged ones; `coolify init upgrade` bumps agent versions across the fleet. After that, **everything dynamic is the v5 control plane's job**: container lifecycle, allow rules in COOLIFY-ALLOW (via systemd dropins for persistence), scheduling, observability, ingress, updates.
The pieces communicate via:
1. **SSH** for host provisioning + re-converge (idempotent `coolify init bootstrap` / `extend` / `upgrade` re-runs). SSH is the installer channel only, not a steady-state control path.
2. **coold → central outbound stream** (WSS / gRPC bidi on :443) for day-to-day runtime ops from central. One topology for self-hosted and cloud SaaS; central never dials coold, never joins any mesh. Per-customer gateway (optional) collapses N streams into 1 per mesh.
3. **coold local REST API** on wg0 mgmt IP (`http://100.64.0.X:8443`) for intra-mesh callers: the `coolify firewall` CLI via SSH-bounce, other coolds, the per-customer gateway. Never reachable from the public internet.
coold is the *only* process with access to the local podman socket AND the sole writer of allow rules in COOLIFY-ALLOW. Both transports hit the same API surface.
Persistence model:
- Bootstrap state (chains, jumps, conntrack accept) → idempotent `coolify init bootstrap` re-runs (and `extend` when a namespace is added).
- Rule metadata (who/when/why, audit, RBAC, tenant scoping) → central Coolify DB only. coold does not duplicate this.
- Kernel rules → programmed by coold on every API call (from either central Coolify or the `coolify firewall` CLI); mirrored to `/etc/coolify/allow.rules` for reboot via `coolify-mesh-allow.service` (oneshot `iptables-restore --noflush`).
- Today the `coolify firewall` CLI is the primary caller of coold (SSH-bounced REST client with per-host `/etc/coolify/api-token` resolution). Central Coolify will call the same API once wired.
The podman socket is host-local. There is no TCP podman API. coold is the **authn + privilege boundary** between any caller (central Coolify over the outbound stream, or the `coolify firewall` CLI via SSH-bounced local REST) and the host, AND the kernel-rule applier. Central Coolify owns RBAC, tenant scoping, and the business audit log (who/when/why). coold only verifies a bearer token (per-host static for local REST; per-host JWT for the stream), applies the rule, and keeps an ops/debug request log. `coolify firewall` exercises the local REST surface today; central will exercise the stream surface — same code path end-to-end, different transport.
**coold stays small.** All app-aware logic (compose, Dockerfile, buildpacks, Nixpacks, scheduling, rollback, ingress templating, RBAC, audit) lives in central. coold's wire surface is enumerable (§2); new verbs require a coold release, not a `/podman/raw` passthrough. If coold ever grows a `/apps` or `/compose` endpoint, that is the wrong layer.
-124
View File
@@ -1,124 +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 via ldflags (into `internal/version.version`)
4. Binaries are automatically uploaded to the release
5. A follow-up `update-version` job then:
- Updates the `version` constant in `internal/version/checker.go` to the new tag
- Commits the bump to `v4.x` as `chore: bump version to vX.Y.Z`
- Force-moves the release tag to point at that new commit
6. GoReleaser also publishes a Homebrew formula to the tap at [`coollabsio/homebrew-coolify-cli`](https://github.com/coollabsio/homebrew-coolify-cli) (under `Formula/coolify-cli.rb`), using the `HOMEBREW_TAP_GITHUB_TOKEN` secret
7. 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`
- Homebrew: `brew install coollabsio/coolify-cli/coolify-cli`
- `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), verify without touching your local install:
1. Check the release page has all platform binaries (Linux/macOS/Windows × amd64/arm64)
2. Confirm the `update-version` job committed the bump on `v4.x` (look for `chore: bump version to vX.Y.Z`) and that the tag now points at that commit
3. Confirm `internal/version/checker.go` on `v4.x` has the new version
4. Confirm the Homebrew tap has a new `Formula/coolify-cli.rb` commit for this version at https://github.com/coollabsio/homebrew-coolify-cli
## 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
- The version is injected at build time via ldflags into `internal/version.version` — you do **not** need to edit it manually before releasing. The post-release `update-version` job also rewrites `internal/version/checker.go` on `v4.x`.
- If the hardcoded fallback in `internal/version/checker.go` is stale, check that the `update-version` job ran successfully after the release.
- The tag must start with `v` (e.g., `v1.2.3`, not `1.2.3`)
- Check that the workflow has write permissions (`contents: write` in `release-cli.yml`)
### 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 ./...`
- [ ] Changes merged to `v4.x` branch
- [ ] Release notes prepared
> Note: You do **not** need to bump the version manually. GoReleaser injects the tag version via ldflags, and the `update-version` CI job commits the bump to `internal/version/checker.go` after the release.
After creating a release:
- [ ] GitHub Actions workflow completed successfully (both `release-cli` and `update-version` jobs)
- [ ] All platform binaries are present on the release page
- [ ] `internal/version/checker.go` on `v4.x` shows the new version
- [ ] Homebrew tap has a fresh `Formula/coolify-cli.rb` commit
## Configuration Files
The release process uses these configuration files:
- `.goreleaser.yml` - GoReleaser configuration (build matrix, archives, Homebrew tap) - entry point is `./coolify/main.go`
- `.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
- [`coollabsio/homebrew-coolify-cli`](https://github.com/coollabsio/homebrew-coolify-cli) - External Homebrew tap updated automatically on each release
## 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`
- Homebrew users can install via `brew install coollabsio/coolify-cli/coolify-cli` (the tap at https://github.com/coollabsio/homebrew-coolify-cli is auto-updated by GoReleaser)
- Releases are immutable - if you need to fix something, create a new patch version
+71 -529
View File
@@ -1,558 +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://gitamin.ir/IranAccess/coolify-cli/raw/branch/v4.x/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
### Homebrew (macOS/Linux)
Alternatively, you can generate a default configuration non-interactively:
```bash
brew install coollabsio/coolify-cli/coolify-cli
coolify init --default
```
#### Windows (PowerShell)
The configuration will be stored in `~/.config/coolify/config.json`.
```powershell
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
```
## Getting Your API Token
It will install the CLI in `%ProgramFiles%\Coolify\coolify.exe` and the configuration file in `%USERPROFILE%\.config\coolify\config.json`
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
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
```
## Managing Instances
For a specific version:
```powershell
$env:COOLIFY_VERSION='v1.0.0'; irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
```
After initialization, you can manage your Coolify instances:
### Using `go install`
```bash
go install github.com/coollabsio/coolify-cli/coolify@latest
```
This will install the `coolify` binary in your `$GOPATH/bin` directory (usually `~/go/bin`). Make sure this directory is in your `$PATH`.
### Using the install script
## Getting Started
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
### Cloud
2. Add the token with `coolify context set-token cloud <token>`
### Self-hosted
2. Add the token with `coolify context add -d <context_name> <url> <token>`
> 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.
Now you can use the CLI with the token you just added.
## For LLMs / AI agents
- Quick instructions: [`llms.txt`](./llms.txt)
- Full command catalog: [`llms-full.txt`](./llms-full.txt)
- Regenerate both files:
### Add a New Instance
```bash
go run ./coolify docs llms
coolify instances add MyInstance https://my.instance.tld mytoken
```
Or use the interactive mode:
```bash
coolify instances add
```
### List All Instances
```bash
coolify instances list
```
### Set Default Instance
```bash
coolify instances set default MyInstance
```
### Remove an Instance
```bash
coolify instances remove MyInstance
```
### Update Instance Token
```bash
coolify instances set token MyInstance newtoken
```
## Change default 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> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--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> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--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
- `--pull-request-id <id>` - Pull request ID for preview deployments
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
- `coolify deploy name <name>` - Deploy a resource by name
- `-f, --force` - Force deployment
- `--pull-request-id <id>` - Pull request ID for preview deployments
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
- `-f, --force` - Force all deployments
- `--pull-request-id <id>` - Pull request ID for preview deployments
- `--docker-tag <tag>` - Docker image tag override for the deployment (requires Coolify `4.0.0-beta.471+`)
- `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
# Deploy a preview with an explicit docker tag
coolify deploy uuid u5ualfp30j27qtfpgcen8p03 --pull-request-id 2345 --docker-tag 1.28.3
# 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
-72
View File
@@ -1,72 +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"
"github.com/coollabsio/coolify-cli/cmd/application/previews"
"github.com/coollabsio/coolify-cli/cmd/application/storage"
)
// 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)
// Add storage subcommand with its children
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage application storages",
Long: `List and manage persistent volumes and file storages for applications.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
// Add previews subcommand with its children
previewsCmd := &cobra.Command{
Use: "previews",
Aliases: []string{"preview"},
Short: "Manage application preview deployments",
Long: `Manage preview deployments created from pull requests. Requires the application UUID.`,
}
previewsCmd.AddCommand(previews.NewDeletePreviewCommand())
cmd.AddCommand(previewsCmd)
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
}
-154
View File
@@ -1,154 +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)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
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")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
-122
View File
@@ -1,122 +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)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
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")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
-129
View File
@@ -1,129 +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)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
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")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
-155
View File
@@ -1,155 +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)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
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")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
-160
View File
@@ -1,160 +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)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
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")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
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"
}
-89
View File
@@ -1,89 +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
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
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", 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)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
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
}
-104
View File
@@ -1,104 +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> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. Identify it 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]
envIdentifier := args[1]
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
}
appSvc := service.NewApplicationService(client)
// Look up the env var to resolve its key
existingEnv, err := appSvc.GetEnv(ctx, appUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.EnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.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 cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
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 (rename)")
cmd.Flags().String("value", "", "New 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")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
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
}
-72
View File
@@ -1,72 +0,0 @@
package previews
import (
"fmt"
"strconv"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeletePreviewCommand() *cobra.Command {
deletePreviewCmd := &cobra.Command{
Use: "delete <app_uuid> <pr_id>",
Short: "Delete a preview deployment",
Long: `Delete a preview deployment for an application. First argument is the application UUID, second is the pull request ID.`,
Args: cli.ExactArgs(2, "<app_uuid> <pr_id>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
prID := args[1]
prIDInt, err := strconv.Atoi(prID)
if err != nil {
return fmt.Errorf("invalid pr_id: must be an integer")
}
if prIDInt <= 0 {
return fmt.Errorf("invalid pr_id: must be a positive integer")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.474"); err != nil {
return 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 the preview deployment for PR %s? (yes/no): ", prID)
_, 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.DeletePreview(ctx, appUUID, prID)
if err != nil {
return fmt.Errorf("failed to delete preview deployment: %w", err)
}
fmt.Printf("Preview deployment for PR %s deleted successfully.\n", prID)
return nil
},
}
deletePreviewCmd.Flags().Bool("force", false, "Skip confirmation prompt")
return deletePreviewCmd
}
-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
},
}
}
-96
View File
@@ -1,96 +0,0 @@
package storage
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"
)
// NewCreateCommand returns the storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <app_uuid>",
Short: "Create a storage for an application",
Long: `Create a persistent volume or file storage for an application.
Examples:
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data --host-path /var/data
coolify app storage create <app_uuid> --type file --mount-path /app/config.yml --content "key: value"
coolify app storage create <app_uuid> --type file --mount-path /app/data --is-directory --fs-path /app/data`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
req := &models.StorageCreateRequest{
Type: storageType,
MountPath: mountPath,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
-43
View File
@@ -1,43 +0,0 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <app_uuid> <storage_uuid>",
Short: "Delete a storage from an application",
Long: `Delete a persistent volume or file storage from an application.
Examples:
coolify app storage delete <app_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<app_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
-51
View File
@@ -1,51 +0,0 @@
package storage
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 returns the storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <app_uuid>",
Short: "List all storages for an application",
Long: `List all persistent volumes and file storages for a specific application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
storages, err := appSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %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(storages)
},
}
}
-117
View File
@@ -1,117 +0,0 @@
package storage
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 returns the storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Short: "Update a storage for an application",
Long: `Update a persistent volume or file storage for an application.
The --uuid and --type flags are required. Use 'coolify app storage list' to find storage UUIDs.
For read-only storages (from docker-compose or services), only --is-preview-suffix-enabled can be updated.
Examples:
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage update <app_uuid> --uuid <storage_uuid> --type file --content "config content" --mount-path /app/config.yml
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
-167
View File
@@ -1,167 +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("dockerfile-target-build") {
targetBuild, _ := cmd.Flags().GetString("dockerfile-target-build")
req.DockerfileTargetBuild = &targetBuild
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("dockerfile-target-build", "", "Dockerfile target build stage")
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
}
-105
View File
@@ -1,105 +0,0 @@
// Package common holds flag structs and helpers shared between the
// `coolify init` and `coolify firewall` command trees. Kept intentionally
// small: only cross-command plumbing (SSH mesh flags, namespace validation)
// lives here.
//
//nolint:revive // "common" is the conventional sharing point for these cobra subtrees
package common
import (
"fmt"
"regexp"
"github.com/spf13/cobra"
)
// DefaultNamespace is the namespace used when the user does not pass
// --namespaces. It is also always present (implicitly) so existing workflows
// and coold defaults keep working.
const DefaultNamespace = "default"
// PodmanNetworkFor returns the podman bridge network name that backs
// namespace ns on every host. Derived as `coolify-<ns>-mesh` so the
// namespace name is visible in `podman network ls`.
func PodmanNetworkFor(ns string) string {
return "coolify-" + ns + "-mesh"
}
// MeshNetFlags holds the flag set shared between `coolify init` (which creates
// per-namespace podman networks on every host) and `coolify firewall` (which
// talks to coold about per-namespace rules).
//
// `init` binds it as a slice so a single command sets up the entire cluster;
// `firewall` binds it as a single value since each allow/revoke/list call
// operates on one namespace at a time.
type MeshNetFlags struct {
// Namespaces enumerates every namespace the mesh should carry. At least
// one entry is required; the first element is the implicit "default"
// unless the user overrides it.
Namespaces []string
// ContainerPool is the shared address pool every namespace carves its
// per-host /<ContainerPrefix> from. One pool covers all namespaces;
// subnets never overlap.
ContainerPool string
// ContainerPrefix is the prefix length of each per-host, per-namespace
// container subnet (default 24 → 254 container IPs per host per ns).
ContainerPrefix int
}
// BindMeshNetMultiFlags registers --namespaces/--container-pool/--container-prefix
// on cmd (init-style: many namespaces per invocation).
func BindMeshNetMultiFlags(cmd *cobra.Command, f *MeshNetFlags) {
pf := cmd.PersistentFlags()
pf.StringSliceVar(&f.Namespaces, "namespaces", []string{DefaultNamespace},
"Comma-separated list of namespaces to create on each host. Each "+
"namespace is a separate Podman bridge network (coolify-<ns>-mesh) "+
"with its own /<container-prefix> per host")
pf.StringVar(&f.ContainerPool, "container-pool", "10.210.0.0/16",
"Shared container address pool — each (namespace, host) pair gets a "+
"/<container-prefix> from here, owned by that namespace's Podman bridge")
pf.IntVar(&f.ContainerPrefix, "container-prefix", 24,
"Prefix length of each per-host, per-namespace container subnet")
}
// BindMeshNetSingleFlags registers --namespace on cmd (firewall-style: one
// namespace per invocation).
func BindMeshNetSingleFlags(cmd *cobra.Command, ns *string) {
pf := cmd.PersistentFlags()
pf.StringVar(ns, "namespace", DefaultNamespace,
"Namespace the command operates against (must match a namespace created by `coolify init`)")
}
// namespaceRegex matches a valid DNS label (namespace names appear in the
// podman network name, in iptables chain names, and — post-coold-changes —
// as DNS labels like web.<ns>.coolify.internal).
var namespaceRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// ValidateNamespaces checks that every namespace is a valid DNS label and
// that the list has no duplicates.
func (f *MeshNetFlags) ValidateNamespaces() error {
if len(f.Namespaces) == 0 {
return fmt.Errorf("--namespaces must list at least one namespace")
}
seen := make(map[string]struct{}, len(f.Namespaces))
for _, ns := range f.Namespaces {
if !namespaceRegex.MatchString(ns) {
return fmt.Errorf("invalid namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
}
if _, dup := seen[ns]; dup {
return fmt.Errorf("duplicate namespace %q in --namespaces", ns)
}
seen[ns] = struct{}{}
}
return nil
}
// ValidateNamespace validates a single namespace value (used by the firewall
// command's --namespace flag).
func ValidateNamespace(ns string) error {
if !namespaceRegex.MatchString(ns) {
return fmt.Errorf("invalid --namespace %q (must be a DNS label: lowercase alphanumerics + '-', 1-63 chars)", ns)
}
return nil
}
-95
View File
@@ -1,95 +0,0 @@
// Package common hosts flag sets and helpers shared between multiple
// top-level commands that SSH into a list of servers (init, firewall, ...).
package common
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
internalssh "github.com/coollabsio/coolify-cli/internal/ssh"
)
// SSHMeshFlags holds the flags shared by every command that fans out over
// a list of SSH-reachable servers (coolify init, coolify firewall, ...).
type SSHMeshFlags struct {
Servers []string
SSHKey string
SSHUser string
SSHPort int
SSHPassphrasePrompt bool
Concurrency int
SSHTimeout string
}
// BindSSHMeshFlags registers the shared flags as PersistentFlags on cmd.
func BindSSHMeshFlags(cmd *cobra.Command, f *SSHMeshFlags) {
pf := cmd.PersistentFlags()
pf.StringSliceVar(&f.Servers, "servers", nil,
"Comma-separated server IPs (required)")
pf.StringVar(&f.SSHKey, "ssh-key", "",
"Path to SSH private key used to connect to servers (required)")
pf.StringVar(&f.SSHUser, "ssh-user", "root",
"SSH username")
pf.IntVar(&f.SSHPort, "ssh-port", 22,
"SSH port")
pf.BoolVar(&f.SSHPassphrasePrompt, "ssh-passphrase-prompt", false,
"Prompt for SSH key passphrase (also reads COOLIFY_SSH_PASSPHRASE env var)")
pf.IntVar(&f.Concurrency, "concurrency", 10,
"Maximum number of parallel SSH connections")
pf.StringVar(&f.SSHTimeout, "ssh-timeout", "30s",
"SSH connection timeout (e.g. 30s, 1m)")
}
// ParseSSHTimeout parses SSHTimeout, falling back to 30s on error/zero.
func (f *SSHMeshFlags) ParseSSHTimeout() time.Duration {
d, err := time.ParseDuration(f.SSHTimeout)
if err != nil || d <= 0 {
return 30 * time.Second
}
return d
}
// ResolvePassphrase returns the SSH key passphrase in this priority order:
// 1. COOLIFY_SSH_PASSPHRASE env var
// 2. Interactive prompt when --ssh-passphrase-prompt is set
// 3. nil (no passphrase)
func (f *SSHMeshFlags) ResolvePassphrase() ([]byte, error) {
if env := os.Getenv("COOLIFY_SSH_PASSPHRASE"); env != "" {
return []byte(env), nil
}
if f.SSHPassphrasePrompt {
fmt.Fprint(os.Stderr, "SSH key passphrase: ")
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, fmt.Errorf("read passphrase: %w", err)
}
return pass, nil
}
return nil, nil
}
// BuildSSHClient creates an SSH client, resolving any key passphrase first.
func (f *SSHMeshFlags) BuildSSHClient() (*internalssh.Client, error) {
passphrase, err := f.ResolvePassphrase()
if err != nil {
return nil, err
}
return internalssh.NewClient(f.SSHKey, passphrase, f.ParseSSHTimeout())
}
// Validate checks that the required flags are set.
func (f *SSHMeshFlags) Validate() error {
if len(f.Servers) == 0 {
return fmt.Errorf("--servers is required")
}
if f.SSHKey == "" {
return fmt.Errorf("--ssh-key is required")
}
return nil
}
-57
View File
@@ -1,57 +0,0 @@
package common
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSSHMeshFlags_ParseSSHTimeout(t *testing.T) {
tests := []struct {
input string
want time.Duration
}{
{"30s", 30 * time.Second},
{"1m", time.Minute},
{"invalid", 30 * time.Second},
{"0s", 30 * time.Second},
{"", 30 * time.Second},
}
for _, tt := range tests {
f := &SSHMeshFlags{SSHTimeout: tt.input}
assert.Equal(t, tt.want, f.ParseSSHTimeout(), "input: %q", tt.input)
}
}
func TestSSHMeshFlags_Validate(t *testing.T) {
t.Run("missing servers", func(t *testing.T) {
err := (&SSHMeshFlags{SSHKey: "/k"}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--servers")
})
t.Run("missing ssh key", func(t *testing.T) {
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}}).Validate()
require.Error(t, err)
assert.Contains(t, err.Error(), "--ssh-key")
})
t.Run("valid", func(t *testing.T) {
err := (&SSHMeshFlags{Servers: []string{"1.1.1.1"}, SSHKey: "/k"}).Validate()
require.NoError(t, err)
})
}
func TestSSHMeshFlags_ResolvePassphrase_Env(t *testing.T) {
t.Setenv("COOLIFY_SSH_PASSPHRASE", "hunter2")
pass, err := (&SSHMeshFlags{}).ResolvePassphrase()
require.NoError(t, err)
assert.Equal(t, []byte("hunter2"), pass)
}
func TestSSHMeshFlags_ResolvePassphrase_NoPrompt(t *testing.T) {
t.Setenv("COOLIFY_SSH_PASSPHRASE", "")
pass, err := (&SSHMeshFlags{SSHPassphrasePrompt: false}).ResolvePassphrase()
require.NoError(t, err)
assert.Nil(t, pass)
}
-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())
},
}
}
-48
View File
@@ -1,48 +0,0 @@
package config
import (
"strings"
"testing"
"github.com/coollabsio/coolify-cli/internal/config"
)
func TestNewConfigCommand(t *testing.T) {
cmd := NewConfigCommand()
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
}
-71
View File
@@ -1,71 +0,0 @@
package database
import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/database/backup"
"github.com/coollabsio/coolify-cli/cmd/database/env"
"github.com/coollabsio/coolify-cli/cmd/database/storage"
)
// 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 env subcommand
envCmd := &cobra.Command{
Use: "env",
Short: "Manage database environment variables",
}
envCmd.AddCommand(env.NewListCommand())
envCmd.AddCommand(env.NewGetCommand())
envCmd.AddCommand(env.NewCreateCommand())
envCmd.AddCommand(env.NewUpdateCommand())
envCmd.AddCommand(env.NewDeleteCommand())
envCmd.AddCommand(env.NewSyncCommand())
cmd.AddCommand(envCmd)
// 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)
// Add storage subcommand
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage database storages",
Long: `List and manage persistent volumes and file storages for databases.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
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
}
-79
View File
@@ -1,79 +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 NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <database_uuid>",
Short: "Create an environment variable for a database",
Long: `Create a new environment variable for a specific database. Use --key and --value flags to specify the variable.`,
Args: cli.ExactArgs(1, "<database_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)
}
key, _ := cmd.Flags().GetString("key")
value, _ := cmd.Flags().GetString("value")
if key == "" {
return fmt.Errorf("--key is required")
}
if value == "" {
return fmt.Errorf("--value is required")
}
req := &models.DatabaseEnvironmentVariableCreateRequest{
Key: key,
Value: value,
}
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("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
dbSvc := service.NewDatabaseService(client)
_, err = dbSvc.CreateEnv(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", key)
return nil
},
}
cmd.Flags().String("key", "", "Environment variable key (required)")
cmd.Flags().String("value", "", "Environment variable value (required)")
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("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
-56
View File
@@ -1,56 +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 NewDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <database_uuid> <env_uuid>",
Short: "Delete an environment variable",
Long: `Delete an environment variable from a database. First UUID is the database, 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()
dbUUID := 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): ")
_, _ = fmt.Scanln(&response)
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
dbSvc := service.NewDatabaseService(client)
err = dbSvc.DeleteEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to delete environment variable: %w", err)
}
fmt.Println("Environment variable deleted successfully.")
return nil
},
}
cmd.Flags().Bool("force", false, "Skip confirmation prompt")
return cmd
}
-57
View File
@@ -1,57 +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 NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <database_uuid> <env_uuid_or_key>",
Short: "Get environment variable details",
Long: `Get detailed information about a specific environment variable. First UUID is the database, second is the environment variable UUID or key name.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbSvc := service.NewDatabaseService(client)
env, err := dbSvc.GetEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to get environment variable: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive value unless --show-sensitive is used
if !showSensitive {
env.Value = "********"
if env.RealValue != nil {
masked := "********"
env.RealValue = &masked
}
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(env)
},
}
}
-58
View File
@@ -1,58 +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 NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <database_uuid>",
Short: "List all environment variables for a database",
Long: `List all environment variables for a specific database.`,
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)
}
dbSvc := service.NewDatabaseService(client)
envs, err := dbSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list environment variables: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive values unless --show-sensitive is used
if !showSensitive {
for i := range envs {
envs[i].Value = "********"
if envs[i].RealValue != nil {
masked := "********"
envs[i].RealValue = &masked
}
}
}
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(envs)
},
}
}
-145
View File
@@ -1,145 +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 NewSyncCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sync <database_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 db 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")
}
isLiteral, _ := cmd.Flags().GetBool("is-literal")
// Parse the .env file
envVars, err := parser.ParseEnvFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse .env file: %w", err)
}
if len(envVars) == 0 {
fmt.Println("No environment variables found in file.")
return nil
}
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
// Fetch existing environment variables
dbSvc := service.NewDatabaseService(client)
existingEnvs, err := dbSvc.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.DatabaseEnvironmentVariable)
for _, env := range existingEnvs {
existingMap[env.Key] = env
}
// Separate into updates and creates
var toUpdate []models.DatabaseEnvironmentVariableCreateRequest
var toCreate []models.DatabaseEnvironmentVariableCreateRequest
for _, envVar := range envVars {
req := models.DatabaseEnvironmentVariableCreateRequest{
Key: envVar.Key,
Value: envVar.Value,
}
// Apply flags if explicitly provided
if cmd.Flags().Changed("is-literal") {
req.IsLiteral = &isLiteral
}
// Auto-detect multiline values
if strings.Contains(envVar.Value, "\n") {
multiline := true
req.IsMultiline = &multiline
}
if _, exists := existingMap[envVar.Key]; exists {
toUpdate = append(toUpdate, req)
} else {
toCreate = append(toCreate, req)
}
}
updateCount := 0
createCount := 0
failCount := 0
// Perform bulk update if there are vars to update
if len(toUpdate) > 0 {
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
bulkReq := &models.DatabaseEnvBulkUpdateRequest{
Data: toUpdate,
}
_, err := dbSvc.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 := dbSvc.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
},
}
cmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
cmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
return cmd
}
-95
View File
@@ -1,95 +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 NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <database_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<database_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envIdentifier := args[1]
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
}
dbSvc := service.NewDatabaseService(client)
// Look up the env var to resolve its key
existingEnv, err := dbSvc.GetEnv(ctx, dbUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.DatabaseEnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
}
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("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
env, err := dbSvc.UpdateEnv(ctx, dbUUID, 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 (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}

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