Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions[bot] b0eb8dbd15 chore: bump version to v1.6.0 2026-03-30 13:06:16 +00:00
Andras Bacsai c292ba8b42 test(deployment): use assert.NoError in deploy service test
Replace `require.NoError` with `assert.NoError` when reading request body in `deployment_test` to keep assertion style consistent in this test block.
2026-03-30 14:10:34 +02:00
Andras Bacsai 4ae6065ecf Merge pull request #67 from toanalien/feature/add-llms-config
feat(docs): enhance llms.txt with overview, data models, short flags, and defaults
2026-03-30 14:03:36 +02:00
Andras Bacsai 80bc511fd8 feat(docs): derive llms aliases from command tree
Refactor `docs` generation to split intro/body content and build the
`Command Aliases` section dynamically from Cobra commands instead of a
hardcoded list.

Add `cmd/docs_test.go` coverage for:
- alias extraction from nested command trees
- flag rendering with shorthand and default values

Regenerate `llms.txt` to reflect the new alias output and updated docs.
2026-03-30 13:58:54 +02:00
Andras Bacsai b2da3013d2 Merge remote-tracking branch 'origin/v4.x' into feature/add-llms-config 2026-03-30 13:54:32 +02:00
Andras Bacsai 28d54b0df9 feat(deployment): support preview deploy fields via request payload
Add shared deploy flags (`--force`, `--pull-request-id`, `--docker-tag`) to
`deploy uuid|name|batch`, and validate `--docker-tag` against minimum
Coolify version `4.0.0-beta.471`.

Refactor deployment triggering to send a structured `DeployRequest` via
`POST /deploy` instead of GET query parameters, enabling optional payload
fields for preview/tagged deployments.

Update deployment service tests to assert POST method and JSON body, and
document the new flags and example usage in the README.
2026-03-30 13:50:52 +02:00
Andras Bacsai c6378a8280 Merge pull request #68 from coollabsio/66-investigate-json-output-breakage
fix(version): write update notices to stderr for JSON-safe output
2026-03-30 13:36:03 +02:00
Andras Bacsai ce0e8fe9cd fix(version): write update notice to stderr to preserve JSON stdout
Redirect the version update message from stdout to stderr so command JSON output stays machine-readable.

Expand checker tests with shared stdout/stderr capture helpers and assertions to verify:
- update notices are emitted on stderr only
- stdout remains clean during checks and errors
- JSON output to stdout is unaffected when an update is available
2026-03-30 13:35:01 +02:00
toanalien 528b1359aa feat(docs): enhance llms.txt with overview, data models, short flags, and defaults
Add comprehensive header to llms.txt including installation, authentication,
configuration, command aliases, usage examples, API notes, and data model
documentation. Update docs generator to output short flags and default values.
2026-03-27 13:48:40 +07:00
Andras Bacsai eabce9a8e1 feat(homebrew): add Homebrew tap support for CLI distribution
Add Homebrew formula configuration to goreleaser for distributing
coolify-cli via the homebrew-coolify-cli tap. This enables macOS/Linux
users to install the CLI with:

  brew install coollabsio/coolify-cli/coolify-cli

Changes include goreleaser formula config, the HOMEBREW_TAP_GITHUB_TOKEN
in the release workflow, and updated installation instructions in README.
2026-03-23 15:54:17 +01:00
15 changed files with 859 additions and 129 deletions
+1
View File
@@ -28,6 +28,7 @@ jobs:
workdir: ./
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
update-version:
needs: [release-cli]
+16 -1
View File
@@ -36,4 +36,19 @@ archives:
format_overrides:
- goos: windows
formats: [zip]
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"
+15
View File
@@ -12,6 +12,12 @@ 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`
### Homebrew (macOS/Linux)
```bash
brew install coollabsio/coolify-cli/coolify-cli
```
#### Windows (PowerShell)
```powershell
@@ -257,10 +263,16 @@ Commands can use `server` or `servers` interchangeably.
### 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
@@ -421,6 +433,9 @@ 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-...
+5 -3
View File
@@ -27,6 +27,9 @@ Example: coolify deploy batch app1,app2,app3`,
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := validateDeployFlags(ctx, cmd, client); err != nil {
return err
}
// Parse comma-separated names
names := make([]string, 0)
@@ -66,7 +69,6 @@ Example: coolify deploy batch app1,app2,app3`,
}
// Deploy all resources
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
type result struct {
@@ -83,7 +85,7 @@ Example: coolify deploy batch app1,app2,app3`,
uuid := nameToUUID[name]
fmt.Printf("Deploying %s...\n", name)
res, err := deploySvc.Deploy(ctx, uuid, force)
res, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
if err != nil {
results = append(results, result{
Name: name,
@@ -126,6 +128,6 @@ Example: coolify deploy batch app1,app2,app3`,
},
}
cmd.Flags().Bool("force", false, "Force deployment")
addDeployFlags(cmd)
return cmd
}
+46 -1
View File
@@ -1,6 +1,16 @@
package deployment
import "github.com/spf13/cobra"
import (
"context"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
)
const dockerTagMinVersion = "4.0.0-beta.471"
// NewDeploymentCommand creates the deployment parent command with all subcommands
func NewDeploymentCommand() *cobra.Command {
@@ -19,3 +29,38 @@ func NewDeploymentCommand() *cobra.Command {
return cmd
}
func addDeployFlags(cmd *cobra.Command) {
cmd.Flags().Bool("force", false, "Force deployment")
cmd.Flags().Int("pull-request-id", 0, "Pull request ID for preview deployments")
cmd.Flags().String("docker-tag", "", "Docker image tag override for the deployment")
}
func getDeployRequest(cmd *cobra.Command, uuid string) models.DeployRequest {
req := models.DeployRequest{
UUID: uuid,
}
if cmd.Flags().Changed("force") {
force, _ := cmd.Flags().GetBool("force")
req.Force = &force
}
if cmd.Flags().Changed("pull-request-id") {
pullRequestID, _ := cmd.Flags().GetInt("pull-request-id")
req.PullRequestID = &pullRequestID
}
if cmd.Flags().Changed("docker-tag") {
dockerTag, _ := cmd.Flags().GetString("docker-tag")
req.DockerTag = &dockerTag
}
return req
}
func validateDeployFlags(ctx context.Context, cmd *cobra.Command, client *api.Client) error {
if cmd.Flags().Changed("docker-tag") {
return cli.CheckMinimumVersion(ctx, client, dockerTagMinVersion)
}
return nil
}
+5 -3
View File
@@ -24,6 +24,9 @@ func NewNameCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := validateDeployFlags(ctx, cmd, client); err != nil {
return err
}
// Find resource by name
resourceSvc := service.NewResourceService(client)
@@ -45,9 +48,8 @@ func NewNameCommand() *cobra.Command {
}
// Deploy using the found UUID
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Deploy(ctx, matchedUUID, force)
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, matchedUUID))
if err != nil {
return fmt.Errorf("failed to deploy resource: %w", err)
}
@@ -74,6 +76,6 @@ func NewNameCommand() *cobra.Command {
},
}
cmd.Flags().Bool("force", false, "Force deployment")
addDeployFlags(cmd)
return cmd
}
+5 -3
View File
@@ -30,10 +30,12 @@ func NewUUIDCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := validateDeployFlags(ctx, cmd, client); err != nil {
return err
}
force, _ := cmd.Flags().GetBool("force")
deploySvc := service.NewDeploymentService(client)
result, err := deploySvc.Deploy(ctx, uuid, force)
result, err := deploySvc.Deploy(ctx, getDeployRequest(cmd, uuid))
if err != nil {
return fmt.Errorf("failed to deploy resource: %w", err)
}
@@ -60,6 +62,6 @@ func NewUUIDCommand() *cobra.Command {
},
}
cmd.Flags().Bool("force", false, "Force deployment")
addDeployFlags(cmd)
return cmd
}
+208 -12
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/spf13/cobra"
@@ -100,6 +101,9 @@ The output file will be written to the specified path (default: ./llms.txt).`,
outputFile, _ := cmd.Flags().GetString("output")
var sb strings.Builder
sb.WriteString(llmsIntro)
writeLLMsAliases(&sb, rootCmd, "coolify")
sb.WriteString(llmsBody)
writeLLMsCommand(&sb, rootCmd, "coolify")
if err := os.WriteFile(outputFile, []byte(sb.String()), 0600); err != nil {
@@ -113,18 +117,204 @@ The output file will be written to the specified path (default: ./llms.txt).`,
},
}
// llmsIntro contains the static overview section prepended to the generated command reference.
const llmsIntro = `# Coolify CLI - llms.txt
> A CLI tool for interacting with the Coolify API, built with Go.
> Manage Coolify instances (cloud and self-hosted), servers, projects, applications, databases, services, deployments, domains, and private keys.
> Source: https://github.com/coollabsio/coolify-cli
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
## Installation
` + "```bash" + `
# Linux/macOS (recommended)
curl -fsSL https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.sh | bash
# Homebrew (macOS/Linux)
brew install coollabsio/coolify-cli/coolify-cli
# Windows (PowerShell)
irm https://raw.githubusercontent.com/coollabsio/coolify-cli/main/scripts/install.ps1 | iex
# Go install
go install github.com/coollabsio/coolify-cli/coolify@latest
` + "```" + `
## Authentication
1. Get an API token from your Coolify dashboard at ` + "`/security/api-tokens`" + `
2. For Coolify Cloud: ` + "`coolify context set-token cloud <token>`" + `
3. For self-hosted: ` + "`coolify context add -d <context_name> <url> <token>`" + `
## Configuration
Config file location:
- Linux/macOS: ` + "`~/.config/coolify/config.json`" + `
- Windows: ` + "`%APPDATA%\\coolify\\config.json`" + `
Supports multiple contexts (instances) with ` + "`coolify context`" + ` commands.
## Output Formats
All commands support ` + "`--format`" + ` flag:
- ` + "`table`" + ` (default) - human-readable tabular output
- ` + "`json`" + ` - compact JSON for scripting
- ` + "`pretty`" + ` - indented JSON for debugging
`
const llmsBody = `
## Supported Database Types
When using ` + "`coolify database create <type>`" + `:
- ` + "`postgresql`" + `
- ` + "`mysql`" + `
- ` + "`mariadb`" + `
- ` + "`mongodb`" + `
- ` + "`redis`" + `
- ` + "`keydb`" + `
- ` + "`clickhouse`" + `
- ` + "`dragonfly`" + `
## Usage Examples
` + "```bash" + `
# Multi-context workflow
coolify context add prod https://prod.coolify.io <token>
coolify context add staging https://staging.coolify.io <token>
coolify context use prod
coolify --context=staging server list
# Application lifecycle
coolify app list
coolify app get <uuid>
coolify app start <uuid>
coolify app stop <uuid>
coolify app restart <uuid>
coolify app logs <uuid> --follow
# Environment variable management
coolify app env list <uuid>
coolify app env create <uuid> --key API_KEY --value secret123
coolify app env sync <uuid> --file .env.production --build-time --preview
# Deploy workflows
coolify deploy name my-application
coolify deploy batch api,worker,frontend --force
coolify deploy list
coolify deploy cancel <uuid>
# Database backup
coolify database backup create <db-uuid> --frequency "0 2 * * *" --enabled --save-s3
coolify database backup trigger <db-uuid> <backup-uuid>
# Application creation
coolify app create public --project-uuid <uuid> --server-uuid <uuid> --git-repository https://github.com/user/repo --git-branch main --build-pack nixpacks --ports-exposes 3000
coolify app create dockerfile --project-uuid <uuid> --server-uuid <uuid> --dockerfile "FROM node:18\nCOPY . .\nRUN npm install\nCMD [\"node\", \"index.js\"]"
coolify app create dockerimage --project-uuid <uuid> --server-uuid <uuid> --docker-registry-image-name nginx --ports-exposes 80
# Service creation (one-click services)
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
coolify service create --list-types # list all available service types
# Storage management
coolify app storage create <app-uuid> --type persistent --mount-path /data --name my-volume
coolify app storage create <app-uuid> --type file --mount-path /app/config.yml --content "key: value"
# GitHub App integration
coolify github list
coolify github repos <app-uuid>
coolify github branches <app-uuid> owner/repo
# Team management
coolify team list
coolify team current
coolify team members list
` + "```" + `
## API Notes
- All resource identifiers use UUIDs (not internal database IDs)
- API base path: ` + "`/api/v1/`" + `
- Authentication: Bearer token via ` + "`--token`" + ` flag or context configuration
- ` + "`app env sync`" + ` behavior: updates existing variables, creates missing ones, does NOT delete variables not in the file
- ` + "`app start`" + ` aliases to ` + "`app deploy`" + ` and also accepts ` + "`--force`" + ` and ` + "`--instant-deploy`" + ` flags
- Deployment logs support ` + "`--follow`" + ` for real-time streaming and ` + "`--debuglogs`" + ` for internal operations
- ` + "`app logs`" + ` defaults to 100 lines; ` + "`app deployments logs`" + ` defaults to 0 (all lines)
- Short flag ` + "`-n`" + ` can be used instead of ` + "`--lines`" + ` for log commands
- ` + "`completion`" + ` command supports shells: ` + "`bash`" + `, ` + "`zsh`" + `, ` + "`fish`" + `, ` + "`powershell`" + `
- Resource statuses: ` + "`running`" + `, ` + "`stopped`" + `, ` + "`error`" + `
- Teams use numeric IDs (not UUIDs) - this is the only resource that uses IDs
- Fields marked ` + "`sensitive:\"true\"`" + ` (tokens, passwords, IPs, emails) are hidden by default; use ` + "`--show-sensitive`" + ` to reveal
---
## Command Reference
`
// writeLLMsAliases writes aliases derived from the Cobra command tree.
func writeLLMsAliases(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
aliases := collectLLMsAliases(cmd, parentPath)
if len(aliases) == 0 {
return
}
sb.WriteString("\n## Command Aliases\n\n")
sb.WriteString("Aliases are derived from the CLI command tree:\n")
for _, aliasLine := range aliases {
fmt.Fprintf(sb, "- %s\n", aliasLine)
}
}
func collectLLMsAliases(cmd *cobra.Command, parentPath string) []string {
var aliases []string
if cmd.Name() != "docs" && cmd.Name() != "help" {
if len(cmd.Aliases) > 0 {
aliasNames := append([]string{cmd.Name()}, cmd.Aliases...)
for i := range aliasNames {
aliasNames[i] = fmt.Sprintf("`%s`", commandPathPrefix(parentPath, cmd)+aliasNames[i])
}
aliases = append(aliases, strings.Join(aliasNames, " | "))
}
}
for _, child := range cmd.Commands() {
if child.Hidden || child.Name() == "help" {
continue
}
aliases = append(aliases, collectLLMsAliases(child, llmsCommandName(parentPath, cmd))...)
}
slices.Sort(aliases)
return slices.Compact(aliases)
}
func llmsCommandName(parentPath string, cmd *cobra.Command) string {
if !cmd.HasParent() {
return parentPath
}
parts := strings.Fields(cmd.Use)
commandPath := parentPath + " " + parts[0]
if len(parts) > 1 {
commandPath += " " + strings.Join(parts[1:], " ")
}
return commandPath
}
func commandPathPrefix(parentPath string, cmd *cobra.Command) string {
if cmd.HasParent() {
return parentPath + " "
}
return ""
}
// writeLLMsCommand recursively writes command documentation in llms.txt format.
func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string) {
// Build the full command path including args from Use field
commandPath := parentPath
if cmd.HasParent() {
parts := strings.Fields(cmd.Use)
commandPath = parentPath + " " + parts[0]
// Append positional args from the Use field (e.g., "<uuid>", "[optional]")
if len(parts) > 1 {
commandPath += " " + strings.Join(parts[1:], " ")
}
}
commandPath := llmsCommandName(parentPath, cmd)
// Skip the docs command itself and help command
if cmd.Name() == "docs" || cmd.Name() == "help" {
@@ -192,10 +382,17 @@ func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string
// or via "(required)" in the usage string
required := isFlagRequired(f)
fmt.Fprintf(sb, " - name: --%s\n", f.Name)
if f.Shorthand != "" {
fmt.Fprintf(sb, " - name: --%s (-%s)\n", f.Name, f.Shorthand)
} else {
fmt.Fprintf(sb, " - name: --%s\n", f.Name)
}
fmt.Fprintf(sb, " type: %s\n", flagType)
fmt.Fprintf(sb, " description: %s\n", f.Usage)
fmt.Fprintf(sb, " required: %t\n", required)
if f.DefValue != "" && f.DefValue != "[]" {
fmt.Fprintf(sb, " default: %s\n", f.DefValue)
}
}
}
@@ -209,8 +406,7 @@ func writeLLMsCommand(sb *strings.Builder, cmd *cobra.Command, parentPath string
}
childPath := parentPath
if cmd.HasParent() {
parts := strings.Fields(cmd.Use)
childPath = parentPath + " " + parts[0]
childPath = llmsCommandName(parentPath, cmd)
}
writeLLMsCommand(sb, child, childPath)
}
+68
View File
@@ -0,0 +1,68 @@
package cmd
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWriteLLMsCommandIncludesShorthandAndDefaults(t *testing.T) {
root := &cobra.Command{Use: "coolify"}
child := &cobra.Command{
Use: "logs <uuid>",
Short: "Show logs",
Run: func(_ *cobra.Command, _ []string) {},
}
child.Flags().IntP("lines", "n", 0, "Number of log lines to display (0 = all)")
child.Flags().Bool("verbose", false, "Verbose output")
child.Flags().Bool("enabled", true, "Enabled by default")
root.AddCommand(child)
var sb strings.Builder
writeLLMsCommand(&sb, child, "coolify")
got := sb.String()
for _, want := range []string{
"Command: coolify logs <uuid>",
" - name: --lines (-n)",
" default: 0",
" - name: --verbose",
" default: false",
" - name: --enabled",
" default: true",
} {
if !strings.Contains(got, want) {
t.Fatalf("expected output to contain %q\nfull output:\n%s", want, got)
}
}
}
func TestWriteLLMsAliasesUsesCommandTree(t *testing.T) {
root := &cobra.Command{Use: "coolify"}
teams := &cobra.Command{Use: "teams", Aliases: []string{"team"}}
members := &cobra.Command{Use: "members", Aliases: []string{"member"}}
start := &cobra.Command{
Use: "start <uuid>",
Aliases: []string{"deploy"},
}
root.AddCommand(teams)
root.AddCommand(start)
teams.AddCommand(members)
var sb strings.Builder
writeLLMsAliases(&sb, root, "coolify")
got := sb.String()
for _, want := range []string{
"## Command Aliases",
"`coolify start` | `coolify deploy`",
"`coolify teams` | `coolify team`",
"`coolify teams members` | `coolify teams member`",
} {
if !strings.Contains(got, want) {
t.Fatalf("expected alias output to contain %q\nfull output:\n%s", want, got)
}
}
}
+8
View File
@@ -23,3 +23,11 @@ type DeployResponse struct {
Message string `json:"message"`
DeploymentUUID string `json:"deployment_uuid,omitempty"`
}
// DeployRequest represents the request to trigger a deployment.
type DeployRequest struct {
UUID string `json:"uuid"`
Force *bool `json:"force,omitempty"`
PullRequestID *int `json:"pull_request_id,omitempty"`
DockerTag *string `json:"docker_tag,omitempty"`
}
+3 -8
View File
@@ -35,16 +35,11 @@ type DeployResponse struct {
}
// Deploy triggers a deployment for a resource
func (s *DeploymentService) Deploy(ctx context.Context, uuid string, force bool) (*DeployResponse, error) {
endpoint := fmt.Sprintf("deploy?uuid=%s", uuid)
if force {
endpoint += "&force=true"
}
func (s *DeploymentService) Deploy(ctx context.Context, req models.DeployRequest) (*DeployResponse, error) {
var response DeployResponse
err := s.client.Get(ctx, endpoint, &response)
err := s.client.Post(ctx, "deploy", req, &response)
if err != nil {
return nil, fmt.Errorf("failed to deploy resource %s: %w", uuid, err)
return nil, fmt.Errorf("failed to deploy resource %s: %w", req.UUID, err)
}
return &response, nil
}
+42 -16
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -11,21 +12,24 @@ import (
"github.com/stretchr/testify/require"
"github.com/coollabsio/coolify-cli/internal/api"
"github.com/coollabsio/coolify-cli/internal/models"
)
func TestDeploymentService_Deploy(t *testing.T) {
tests := []struct {
name string
uuid string
force bool
expectedPath string
response DeployResponse
name string
request models.DeployRequest
expectedPath string
expectedMethod string
expectedBody string
response DeployResponse
}{
{
name: "deploy without force",
uuid: "res-123",
force: false,
expectedPath: "/api/v1/deploy?uuid=res-123",
name: "deploy without optional fields",
request: models.DeployRequest{UUID: "res-123"},
expectedPath: "/api/v1/deploy",
expectedMethod: "POST",
expectedBody: `{"uuid":"res-123"}`,
response: DeployResponse{
Deployments: []DeploymentInfo{
{
@@ -37,10 +41,16 @@ func TestDeploymentService_Deploy(t *testing.T) {
},
},
{
name: "deploy with force",
uuid: "res-789",
force: true,
expectedPath: "/api/v1/deploy?uuid=res-789&force=true",
name: "deploy with force and extra payload fields",
request: models.DeployRequest{
UUID: "res-789",
Force: deployBoolPtr(true),
PullRequestID: deployIntPtr(2345),
DockerTag: deployStringPtr("1.28.3"),
},
expectedPath: "/api/v1/deploy",
expectedMethod: "POST",
expectedBody: `{"uuid":"res-789","force":true,"pull_request_id":2345,"docker_tag":"1.28.3"}`,
response: DeployResponse{
Deployments: []DeploymentInfo{
{
@@ -56,8 +66,12 @@ func TestDeploymentService_Deploy(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, tt.expectedPath, r.URL.Path+"?"+r.URL.RawQuery)
assert.Equal(t, "GET", r.Method)
assert.Equal(t, tt.expectedPath, r.URL.Path)
assert.Equal(t, tt.expectedMethod, r.Method)
body, err := io.ReadAll(r.Body)
assert.NoError(t, err)
assert.JSONEq(t, tt.expectedBody, string(body))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(tt.response)
@@ -67,7 +81,7 @@ func TestDeploymentService_Deploy(t *testing.T) {
client := api.NewClient(server.URL, "test-token")
svc := NewDeploymentService(client)
result, err := svc.Deploy(context.Background(), tt.uuid, tt.force)
result, err := svc.Deploy(context.Background(), tt.request)
require.NoError(t, err)
assert.Len(t, result.Deployments, len(tt.response.Deployments))
if len(result.Deployments) > 0 {
@@ -78,6 +92,18 @@ func TestDeploymentService_Deploy(t *testing.T) {
}
}
func deployBoolPtr(v bool) *bool {
return &v
}
func deployIntPtr(v int) *int {
return &v
}
func deployStringPtr(v string) *string {
return &v
}
func TestDeploymentService_ListByApplication(t *testing.T) {
tests := []struct {
name string
+3 -2
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"sort"
"time"
@@ -14,7 +15,7 @@ import (
// Version variables injected by GoReleaser at build time via ldflags
var (
version = "v1.5.0"
version = "v1.6.0"
)
// GitHubAPIURL is the URL for fetching CLI version tags (exported for testing)
@@ -101,7 +102,7 @@ func CheckLatestVersionOfCli(_ bool) (string, error) {
}
if latestVersion.GreaterThan(currentVersion) {
fmt.Printf("A new version (%s) is available. Update with: coolify update\n", latestVersion.String())
_, _ = fmt.Fprintf(os.Stderr, "A new version (%s) is available. Update with: coolify update\n", latestVersion.String())
}
return latestVersion.String(), nil
}
+113 -61
View File
@@ -9,6 +9,39 @@ import (
"testing"
)
func captureOutput(t *testing.T, fn func()) (string, string) {
t.Helper()
oldStdout := os.Stdout
oldStderr := os.Stderr
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() stdout error = %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() stderr error = %v", err)
}
os.Stdout = stdoutW
os.Stderr = stderrW
fn()
_ = stdoutW.Close()
_ = stderrW.Close()
os.Stdout = oldStdout
os.Stderr = oldStderr
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
_, _ = io.Copy(&stdoutBuf, stdoutR)
_, _ = io.Copy(&stderrBuf, stderrR)
return stdoutBuf.String(), stderrBuf.String()
}
func TestGetVersion(t *testing.T) {
v := GetVersion()
if v == "" {
@@ -42,19 +75,11 @@ func TestCheckLatestVersionOfCli_UpdateAvailable(t *testing.T) {
GitHubAPIURL = server.URL
// Capture stdout to check for update message
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
latestVersion, err := CheckLatestVersionOfCli(false)
_ = w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
output := buf.String()
var latestVersion string
var err error
stdout, stderr := captureOutput(t, func() {
latestVersion, err = CheckLatestVersionOfCli(false)
})
if err != nil {
t.Errorf("CheckLatestVersionOfCli() error = %v, want nil", err)
@@ -64,10 +89,15 @@ func TestCheckLatestVersionOfCli_UpdateAvailable(t *testing.T) {
t.Errorf("CheckLatestVersionOfCli() latestVersion = %q, want %q", latestVersion, "2.0.0")
}
// Should print update message
// Should not write anything to stdout
if stdout != "" {
t.Errorf("CheckLatestVersionOfCli() stdout = %q, want empty string", stdout)
}
// Should print update message to stderr
expectedMsg := "A new version (2.0.0) is available. Update with: coolify update\n"
if output != expectedMsg {
t.Errorf("CheckLatestVersionOfCli() output = %q, want %q", output, expectedMsg)
if stderr != expectedMsg {
t.Errorf("CheckLatestVersionOfCli() stderr = %q, want %q", stderr, expectedMsg)
}
}
@@ -92,19 +122,11 @@ func TestCheckLatestVersionOfCli_NoUpdate(t *testing.T) {
GitHubAPIURL = server.URL
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
latestVersion, err := CheckLatestVersionOfCli(false)
_ = w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
output := buf.String()
var latestVersion string
var err error
stdout, stderr := captureOutput(t, func() {
latestVersion, err = CheckLatestVersionOfCli(false)
})
if err != nil {
t.Errorf("CheckLatestVersionOfCli() error = %v, want nil", err)
@@ -116,8 +138,12 @@ func TestCheckLatestVersionOfCli_NoUpdate(t *testing.T) {
}
// Should NOT print any message when already on latest (current v99.99.99 > latest v2.0.0)
if output != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything when on latest version, got: %q", output)
if stdout != "" {
t.Errorf("CheckLatestVersionOfCli() should not write to stdout when on latest version, got: %q", stdout)
}
if stderr != "" {
t.Errorf("CheckLatestVersionOfCli() should not write to stderr when on latest version, got: %q", stderr)
}
}
@@ -137,19 +163,11 @@ func TestCheckLatestVersionOfCli_APIError_SilentFail(t *testing.T) {
GitHubAPIURL = server.URL
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
latestVersion, err := CheckLatestVersionOfCli(false)
_ = w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
output := buf.String()
var latestVersion string
var err error
stdout, stderr := captureOutput(t, func() {
latestVersion, err = CheckLatestVersionOfCli(false)
})
// Should return empty string and nil error (silent fail)
if err != nil {
@@ -161,8 +179,12 @@ func TestCheckLatestVersionOfCli_APIError_SilentFail(t *testing.T) {
}
// Should NOT print anything on error
if output != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything on API error, got: %q", output)
if stdout != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything to stdout on API error, got: %q", stdout)
}
if stderr != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything to stderr on API error, got: %q", stderr)
}
}
@@ -176,19 +198,11 @@ func TestCheckLatestVersionOfCli_NetworkError_SilentFail(t *testing.T) {
// Use invalid URL to cause network error
GitHubAPIURL = "http://localhost:1" // Port 1 should fail to connect
// Capture stdout
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
latestVersion, err := CheckLatestVersionOfCli(false)
_ = w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
output := buf.String()
var latestVersion string
var err error
stdout, stderr := captureOutput(t, func() {
latestVersion, err = CheckLatestVersionOfCli(false)
})
// Should return empty string and nil error (silent fail)
if err != nil {
@@ -200,8 +214,46 @@ func TestCheckLatestVersionOfCli_NetworkError_SilentFail(t *testing.T) {
}
// Should NOT print anything on error
if output != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything on network error, got: %q", output)
if stdout != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything to stdout on network error, got: %q", stdout)
}
if stderr != "" {
t.Errorf("CheckLatestVersionOfCli() should not print anything to stderr on network error, got: %q", stderr)
}
}
func TestCheckLatestVersionOfCli_UpdateAvailable_LeavesStdoutAvailableForJSON(t *testing.T) {
originalURL := GitHubAPIURL
originalVersion := version
defer func() {
GitHubAPIURL = originalURL
version = originalVersion
}()
version = "v0.0.1"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`[{"ref":"refs/tags/v2.0.0"}]`))
}))
defer server.Close()
GitHubAPIURL = server.URL
stdout, stderr := captureOutput(t, func() {
_, _ = CheckLatestVersionOfCli(false)
_, _ = os.Stdout.WriteString(`[{"uuid":"demo"}]` + "\n")
})
expectedStdout := `[{"uuid":"demo"}]` + "\n"
if stdout != expectedStdout {
t.Fatalf("stdout = %q, want %q", stdout, expectedStdout)
}
expectedStderr := "A new version (2.0.0) is available. Update with: coolify update\n"
if stderr != expectedStderr {
t.Fatalf("stderr = %q, want %q", stderr, expectedStderr)
}
}
+321 -19
View File
File diff suppressed because it is too large Load Diff