15 Commits

Author SHA1 Message Date
Andras Bacsai 6b742d37dd Update checker.go 2025-10-17 11:35:47 +02:00
Andras Bacsai 0b2d277e41 Merge pull request #27 from coollabsio/contributing-guide
Add CONTRIBUTING.md file
2025-10-17 11:35:23 +02:00
Andras Bacsai ac9a486d46 Merge pull request #28 from coollabsio/andrasbacsai/add-min-version-reqs
Add minimum version checks to CLI commands
2025-10-17 11:35:15 +02:00
Andras Bacsai ba8f05769f Changes auto-committed by Conductor 2025-10-17 11:34:49 +02:00
Andras Bacsai 5c799410e5 Changes auto-committed by Conductor 2025-10-17 11:33:48 +02:00
Andras Bacsai d5dd1b5bdf Changes auto-committed by Conductor 2025-10-17 11:32:53 +02:00
Andras Bacsai 0d6a2bb1e9 Changes auto-committed by Conductor 2025-10-17 11:32:53 +02:00
Andras Bacsai 9eba5b97f7 Merge pull request #26 from coollabsio/andrasbacsai/check-readme
Update README with CLI command fixes
2025-10-17 11:26:10 +02:00
Andras Bacsai 76c1434711 Changes auto-committed by Conductor 2025-10-17 11:24:00 +02:00
Andras Bacsai f35d299f6e Update README.md 2025-10-17 11:17:17 +02:00
Andras Bacsai 680264ab3f Update README.md 2025-10-17 11:15:38 +02:00
Andras Bacsai 794025aee1 Changes auto-committed by Conductor (#25) 2025-10-17 11:14:24 +02:00
Andras Bacsai 8d6da93aa1 Merge pull request #24 from coollabsio/andrasbacsai/show-config-path
Add coolify config command
2025-10-17 11:05:57 +02:00
Andras Bacsai 22516fe51e Changes auto-committed by Conductor 2025-10-17 11:00:09 +02:00
Andras Bacsai fe63c3a3b6 Changes auto-committed by Conductor 2025-10-17 10:59:12 +02:00
20 changed files with 986 additions and 107 deletions
+620
View File
@@ -0,0 +1,620 @@
# 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 .
# Install locally
go install
```
### Running the CLI
```bash
# Run without installing
go run main.go [command]
# Example commands
go run main.go context list
go run main.go server list --debug
# With flags
go run main.go 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 := context.Background()
// 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(context.Background())
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! 🚀
+31 -35
View File
@@ -8,8 +8,6 @@ curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts
It will install the CLI in `/usr/local/bin/coolify` and the configuration file in `~/.config/coolify/config.json`
> If you are a windows or mac user, please test the installation script and let us know if it works for you.
## Getting Started
1. Get a `<token>` from your Coolify dashboard (Cloud or self-hosted) at `/security/api-tokens`
@@ -34,6 +32,13 @@ You can change the default context with `coolify context use <context_name>` or
### 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
@@ -48,25 +53,23 @@ You can change the default context with `coolify context use <context_name>` or
- `--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
### Servers
- `coolify servers list` - List all servers
- `coolify servers get <uuid>` - Get a server by UUID
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
<<<<<<< HEAD
- `coolify servers add <server_name> <ip_address> <private_key_uuid>` - Add a new server
- `--port <port>` - SSH port (default: 22)
- `--user <user>` - SSH user (default: root)
=======
- `coolify servers add <name> <ip> <private_key_uuid>` - Add a new 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)
>>>>>>> origin/v4.x
- `--validate` - Validate server immediately after adding
- `coolify servers remove <uuid>` - Remove a server
- `coolify servers validate <uuid>` - Validate a server connection
- `coolify servers domains <uuid>` - Get server domains by UUID
- `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
@@ -190,15 +193,9 @@ You can change the default context with `coolify context use <context_name>` or
### Deployments
- `coolify deploy uuid <uuid>` - Deploy a resource by UUID
<<<<<<< HEAD
- `--force` - Force deployment
- `coolify deploy name <resource_name>` - Deploy a resource by name
- `--force` - Force deployment
=======
- `-f, --force` - Force deployment
- `coolify deploy name <name>` - Deploy a resource by name
- `-f, --force` - Force deployment
>>>>>>> origin/v4.x
- `coolify deploy batch <name1,name2,...>` - Deploy multiple resources at once
- `-f, --force` - Force all deployments
- `coolify deploy list` - List all deployments
@@ -236,20 +233,19 @@ You can change the default context with `coolify context use <context_name>` or
- `coolify team members list [team_id]` - List team members
### Private Keys
- `coolify privatekeys list` - List all private keys
- `coolify privatekeys create <key_name> <private-key>` - Create a new private key
- Use `@filename` to read from file: `coolify privatekeys create mykey @~/.ssh/id_rsa`
- `coolify privatekeys delete <uuid>` - Delete a private key
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:
<<<<<<< HEAD
- `--context <name>` - Use a specific context instead of default
=======
- `--instance <name>` - Use a specific instance profile instead of default
>>>>>>> origin/v4.x
- `--host <fqdn>` - Override the Coolify instance hostname
- `--token <token>` - Override the authentication token
- `--format <format>` - Output format: `table` (default), `json`, or `pretty`
@@ -414,13 +410,13 @@ coolify team members list
```bash
# List servers in production
coolify --context=prod servers list
coolify --context=prod server list
# Add a server with validation
coolify servers add myserver 192.168.1.100 <key-uuid> --validate
coolify server add myserver 192.168.1.100 <key-uuid> --validate
# Get server details with resources
coolify servers get <uuid> --resources
coolify server get <uuid> --resources
```
## Output Formats
@@ -429,13 +425,13 @@ The CLI supports three output formats:
```bash
# Table format (default, human-readable)
coolify servers list
coolify server list
# JSON format (for scripts)
coolify servers list --format=json
coolify server list --format=json
# Pretty JSON (for debugging)
coolify servers list --format=pretty
coolify server list --format=pretty
```
## Architecture
@@ -464,7 +460,7 @@ go install
## Contributing
Contributions are welcome! Please check the [restructure documentation](RESTRUCTURE_PLAN.md) for architecture guidelines.
Contributions are welcome!
## License
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"fmt"
"github.com/coollabsio/coolify-cli/internal/config"
"github.com/spf13/cobra"
)
// 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(cmd *cobra.Command, args []string) {
fmt.Println(config.Path())
},
}
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"strings"
"testing"
"github.com/coollabsio/coolify-cli/internal/config"
)
func TestNewConfigCommand(t *testing.T) {
cmd := NewConfigCommand()
if cmd == nil {
t.Fatal("NewConfigCommand() returned nil")
}
if cmd.Use != "config" {
t.Errorf("Expected Use to be 'config', got '%s'", cmd.Use)
}
if cmd.Short == "" {
t.Error("Short description should not be empty")
}
if cmd.Long == "" {
t.Error("Long description should not be empty")
}
if cmd.Run == nil {
t.Error("Run function should not be nil")
}
}
func TestConfigCommand_Output(t *testing.T) {
// Test that the command returns the expected config path
expectedPath := config.Path()
// The path should not be empty
if expectedPath == "" {
t.Error("Expected config path to not be empty")
}
// The path should end with config.json
if !strings.HasSuffix(expectedPath, "config.json") {
t.Errorf("Expected path to end with 'config.json', got '%s'", expectedPath)
}
// The path should contain the coolify directory
if !strings.Contains(expectedPath, "coolify") {
t.Errorf("Expected path to contain 'coolify', got '%s'", expectedPath)
}
}
+1
View File
@@ -22,6 +22,7 @@ func NewContextCommand() *cobra.Command {
cmd.AddCommand(NewSetTokenCommand())
cmd.AddCommand(NewSetDefaultCommand())
cmd.AddCommand(NewVersionCommand())
cmd.AddCommand(NewVerifyCommand())
return cmd
}
+41
View File
@@ -0,0 +1,41 @@
package context
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/spf13/cobra"
)
// 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, args []string) error {
ctx := context.Background()
// 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
},
}
}
+105
View File
@@ -0,0 +1,105 @@
package context
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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, r *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, r *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, r *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)
}
+5
View File
@@ -29,6 +29,11 @@ Example: coolify database backup create abc123 --frequency "0 0 * * *" --enabled
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
+5
View File
@@ -26,6 +26,11 @@ func NewCancelCommand() *cobra.Command {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.436"); err != nil {
return err
}
force, _ := cmd.Flags().GetBool("force")
// Prompt for confirmation unless --force is used
+5
View File
@@ -23,6 +23,11 @@ func NewListCommand() *cobra.Command {
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
}
svc := service.NewGitHubAppService(client)
apps, err := svc.List(ctx)
if err != nil {
+3 -1
View File
@@ -8,6 +8,7 @@ import (
"github.com/coollabsio/coolify-cli/cmd/application"
"github.com/coollabsio/coolify-cli/cmd/completion"
configcmd "github.com/coollabsio/coolify-cli/cmd/config"
"github.com/coollabsio/coolify-cli/cmd/context"
"github.com/coollabsio/coolify-cli/cmd/database"
"github.com/coollabsio/coolify-cli/cmd/deployment"
@@ -86,8 +87,9 @@ func init() {
// Register all subcommands
rootCmd.AddCommand(application.NewAppCommand())
rootCmd.AddCommand(context.NewContextCommand())
rootCmd.AddCommand(completion.NewCompletionsCommand())
rootCmd.AddCommand(configcmd.NewConfigCommand())
rootCmd.AddCommand(context.NewContextCommand())
rootCmd.AddCommand(database.NewDatabaseCommand())
rootCmd.AddCommand(deployment.NewDeploymentCommand())
rootCmd.AddCommand(github.NewGitHubCommand())
+27
View File
@@ -1,8 +1,11 @@
package cli
import (
"context"
"fmt"
"github.com/coollabsio/coolify-cli/internal/api"
compareVersion "github.com/hashicorp/go-version"
"github.com/spf13/cobra"
)
@@ -69,3 +72,27 @@ func StringPtr(s string) *string {
func BoolPtr(b bool) *bool {
return &b
}
// CheckMinimumVersion checks if the Coolify API version meets the minimum requirement
func CheckMinimumVersion(ctx context.Context, client *api.Client, minimumVersion string) error {
currentVersionStr, err := client.GetVersion(ctx)
if err != nil {
return fmt.Errorf("failed to get Coolify version: %w", err)
}
currentVersion, err := compareVersion.NewVersion(currentVersionStr)
if err != nil {
return fmt.Errorf("invalid current version '%s': %w", currentVersionStr, err)
}
minVersion, err := compareVersion.NewVersion(minimumVersion)
if err != nil {
return fmt.Errorf("invalid minimum version '%s': %w", minimumVersion, err)
}
if currentVersion.LessThan(minVersion) {
return fmt.Errorf("this command requires Coolify version %s or higher, but the current version is %s", minimumVersion, currentVersionStr)
}
return nil
}
+1 -1
View File
@@ -556,7 +556,7 @@ func TestPath(t *testing.T) {
// Windows path should contain either AppData or backslashes
assert.True(t,
filepath.Separator == '\\' &&
(os.Getenv("APPDATA") != "" || filepath.IsAbs(path)),
(os.Getenv("APPDATA") != "" || filepath.IsAbs(path)),
"Windows path should be valid",
)
}
+30 -30
View File
@@ -26,20 +26,20 @@ type ApplicationListItem struct {
// ApplicationUpdateRequest represents the request to update an application
// All fields are optional - only provided fields will be updated
type ApplicationUpdateRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
GitBranch *string `json:"git_branch,omitempty"`
GitRepository *string `json:"git_repository,omitempty"`
GitCommitSHA *string `json:"git_commit_sha,omitempty"`
Domains *string `json:"domains,omitempty"`
BuildCommand *string `json:"build_command,omitempty"`
StartCommand *string `json:"start_command,omitempty"`
InstallCommand *string `json:"install_command,omitempty"`
BaseDirectory *string `json:"base_directory,omitempty"`
PublishDirectory *string `json:"publish_directory,omitempty"`
BuildPack *string `json:"build_pack,omitempty"`
PortsExposes *string `json:"ports_exposes,omitempty"`
PortsMappings *string `json:"ports_mappings,omitempty"`
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
GitBranch *string `json:"git_branch,omitempty"`
GitRepository *string `json:"git_repository,omitempty"`
GitCommitSHA *string `json:"git_commit_sha,omitempty"`
Domains *string `json:"domains,omitempty"`
BuildCommand *string `json:"build_command,omitempty"`
StartCommand *string `json:"start_command,omitempty"`
InstallCommand *string `json:"install_command,omitempty"`
BaseDirectory *string `json:"base_directory,omitempty"`
PublishDirectory *string `json:"publish_directory,omitempty"`
BuildPack *string `json:"build_pack,omitempty"`
PortsExposes *string `json:"ports_exposes,omitempty"`
PortsMappings *string `json:"ports_mappings,omitempty"`
// Docker configuration
Dockerfile *string `json:"dockerfile,omitempty"`
@@ -72,9 +72,9 @@ type ApplicationUpdateRequest struct {
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty"`
// Deployment hooks
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty"`
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty"`
PostDeploymentCommand *string `json:"post_deployment_command,omitempty"`
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty"`
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty"`
PostDeploymentCommand *string `json:"post_deployment_command,omitempty"`
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty"`
// Misc
@@ -112,21 +112,21 @@ type EnvironmentVariable struct {
// EnvironmentVariableCreateRequest represents the request to create an environment variable
type EnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
}
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
type EnvironmentVariableUpdateRequest struct {
UUID string `json:"uuid"`
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
UUID string `json:"uuid"`
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
}
+1 -1
View File
@@ -13,7 +13,7 @@ type UUID struct {
}
// Timestamps for created/updated times
type Timestamps struct{
type Timestamps struct {
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
+22 -22
View File
@@ -166,28 +166,28 @@ type DatabaseLifecycleResponse struct {
// DatabaseBackup represents a scheduled database backup configuration
type DatabaseBackup struct {
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Description *string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Frequency *string `json:"frequency,omitempty"`
SaveS3 *bool `json:"save_s3,omitempty"`
S3StorageID *int `json:"-" table:"-"`
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
DumpAll *bool `json:"dump_all,omitempty"`
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
DatabaseType *string `json:"database_type,omitempty" table:"-"`
DatabaseID *int `json:"-" table:"-"`
TeamID *int `json:"-" table:"-"`
Timeout *int `json:"timeout,omitempty"`
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Description *string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Frequency *string `json:"frequency,omitempty"`
SaveS3 *bool `json:"save_s3,omitempty"`
S3StorageID *int `json:"-" table:"-"`
DatabasesToBackup *string `json:"databases_to_backup,omitempty"`
DumpAll *bool `json:"dump_all,omitempty"`
DatabaseBackupRetentionAmountLocally *int `json:"database_backup_retention_amount_locally,omitempty"`
DatabaseBackupRetentionDaysLocally *int `json:"database_backup_retention_days_locally,omitempty"`
DatabaseBackupRetentionMaxStorageLocally *string `json:"database_backup_retention_max_storage_locally,omitempty"`
DatabaseBackupRetentionAmountS3 *int `json:"database_backup_retention_amount_s3,omitempty"`
DatabaseBackupRetentionDaysS3 *int `json:"database_backup_retention_days_s3,omitempty"`
DatabaseBackupRetentionMaxStorageS3 *string `json:"database_backup_retention_max_storage_s3,omitempty"`
DatabaseType *string `json:"database_type,omitempty" table:"-"`
DatabaseID *int `json:"-" table:"-"`
TeamID *int `json:"-" table:"-"`
Timeout *int `json:"timeout,omitempty"`
DisableLocalBackup *bool `json:"disable_local_backup,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// DatabaseBackupCreateRequest represents the request to create a backup configuration
+2 -2
View File
@@ -246,8 +246,8 @@ func TestTableFormatter_NilPointer(t *testing.T) {
func TestTableFormatter_SliceField(t *testing.T) {
type TestStruct struct {
Name string `json:"name"`
Tags []string `json:"tags"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
data := []TestStruct{
+9 -9
View File
@@ -280,15 +280,15 @@ func TestDatabaseService_Update(t *testing.T) {
func TestDatabaseService_Delete(t *testing.T) {
tests := []struct {
name string
uuid string
deleteConfigurations bool
deleteVolumes bool
dockerCleanup bool
deleteConnectedNetworks bool
statusCode int
wantErr bool
expectedQueryString string
name string
uuid string
deleteConfigurations bool
deleteVolumes bool
dockerCleanup bool
deleteConnectedNetworks bool
statusCode int
wantErr bool
expectedQueryString string
}{
{
name: "successful delete with all cleanup",
+5 -5
View File
@@ -14,11 +14,11 @@ import (
func TestDeploymentService_Deploy(t *testing.T) {
tests := []struct {
name string
uuid string
force bool
expectedPath string
response DeployResponse
name string
uuid string
force bool
expectedPath string
response DeployResponse
}{
{
name: "deploy without force",
+1 -1
View File
@@ -14,7 +14,7 @@ import (
)
// CliVersion is the CLI version
const CliVersion = "1.0.1"
const CliVersion = "1.0.3"
// CheckInterval for version checking
const CheckInterval = 10 * time.Minute