Thank you for your interest in contributing to the Coolify CLI! This document provides guidelines and instructions for contributing to the project.
- Getting Started
- Development Setup
- Project Architecture
- Adding a New Command
- Testing Requirements
- Code Style & Conventions
- Submitting Changes
Before you start contributing:
- Read the ARCHITECTURE.md for detailed architectural guidance
- Review the OpenAPI specification to understand available API endpoints
- Check existing issues to see if your feature/bug is already being worked on
- Open an issue to discuss your proposed changes (for large features)
- Go 1.24 or higher
- Git
# 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# 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 --debugcmd/ # 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
The Coolify CLI follows a layered architecture:
User → Commands (cmd/) → Services (internal/service/) → API Client (internal/api/) → Coolify API
-
Command Layer (
cmd/)- Parse CLI arguments and flags
- Call service layer methods
- Format output using output formatters
-
Service Layer (
internal/service/)- Business logic
- Coordinate API calls
- Transform data
-
API Client Layer (
internal/api/)- HTTP communication
- Retry logic with exponential backoff
- Authentication (Bearer tokens)
- Error handling
- cobra: CLI framework
- viper: Configuration management
- stretchr/testify: Testing assertions
Follow these steps to add a new command:
# Create directory for your command
mkdir -p cmd/myfeatureCreate cmd/myfeature/myfeature.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
}Create cmd/myfeature/list.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)
},
}
}Create internal/service/myfeature.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)
}Create internal/models/myfeature.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.
Add your command to cmd/root.go:
import (
// ... existing imports
"github.com/coollabsio/coolify-cli/cmd/myfeature"
)
func init() {
// ... existing code
rootCmd.AddCommand(myfeature.NewMyFeatureCommand())
}Create internal/service/myfeature_test.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)
}- Add command documentation to
README.md - Include usage examples and flag descriptions
All code changes MUST include tests. This is non-negotiable.
- Minimum coverage: 70% for all packages
- New features: 80%+ coverage required
- Bug fixes: Must include regression tests
- Refactoring: Must maintain or improve existing coverage
# 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.outfunc 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)
}
})
}
}IMPORTANT: Never call real APIs in tests. Use httptest.NewServer():
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 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
- Follow standard Go idioms and conventions
- Use
gofmtfor code formatting - Run
go vetto catch common issues - Prefer standard library over external dependencies
// 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)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
}// 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")
}
}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:
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
debug, _ := cmd.Flags().GetBool("debug")# 1. Format code
go fmt ./...
# 2. Run tests
go test ./internal/...
# 3. Check coverage
go test ./internal/... -cover
# 4. Run vet
go vet ./...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
- Fork the repository
- Create a branch from
v4.x:git checkout -b feature/my-feature v4.x - Make your changes with tests
- Push to your fork:
git push origin feature/my-feature - Open a pull request against the
v4.xbranch - Describe your changes clearly in the PR description
- Link related issues using "Fixes #123" or "Closes #123"
- 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
Releases are automated using GoReleaser:
- Tag a new version:
git tag v1.2.3 - Push the tag:
git push origin v1.2.3 - Create a GitHub release
- GoReleaser builds binaries for all platforms automatically
- Discord: https://coolify.io/discord
- Issues: Open an issue for bugs or feature requests
- Architecture: Read ARCHITECTURE.md for detailed design documentation
- API Reference: See the OpenAPI specification
- Code Guidance: See CLAUDE.md for AI assistant guidance
By contributing, you agree that your contributions will be licensed under the same license as the project.
Thank you for contributing to Coolify CLI! 🚀