forked from mirror/coolify-cli
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c45a0b35ec | |||
| 5e2b3d08db | |||
| b0eb8dbd15 | |||
| c292ba8b42 | |||
| 4ae6065ecf | |||
| 80bc511fd8 | |||
| b2da3013d2 | |||
| 28d54b0df9 | |||
| c6378a8280 | |||
| ce0e8fe9cd | |||
| 528b1359aa | |||
| eabce9a8e1 |
@@ -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]
|
||||
|
||||
@@ -49,10 +49,10 @@ jobs:
|
||||
run: go run ./coolify docs llms
|
||||
|
||||
- name: Check uncommitted changes
|
||||
run: git diff --exit-code llms.txt
|
||||
run: git diff --exit-code llms.txt llms-full.txt
|
||||
|
||||
- if: failure()
|
||||
run: echo "::error::llms.txt is out of date. Run 'go run ./coolify docs llms' and commit the changes."
|
||||
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
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
run: git diff --exit-code
|
||||
|
||||
- if: failure()
|
||||
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
|
||||
run: echo "::error::Check failed, please run 'go mod tidy' and commit the changes."
|
||||
|
||||
+16
-1
@@ -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"
|
||||
@@ -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
|
||||
@@ -58,6 +64,16 @@ This will install the `coolify` binary in your `$GOPATH/bin` directory (usually
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
go run ./coolify docs llms
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -257,10 +273,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 +443,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-...
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+415
-28
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -89,42 +90,421 @@ The markdown files will be written to the specified directory (default: ./docs).
|
||||
|
||||
var llmsCmd = &cobra.Command{
|
||||
Use: "llms",
|
||||
Short: "Generate llms.txt for AI agent command specification",
|
||||
Long: `Generate a machine-readable llms.txt file that defines all CLI commands and their parameters.
|
||||
Short: "Generate llms.txt and llms-full.txt for AI agents",
|
||||
Long: `Generate AI-friendly documentation files for the Coolify CLI.
|
||||
|
||||
This file is intended to enable AI agents to understand and interact with the CLI.
|
||||
The output file will be written to the specified path (default: ./llms.txt).`,
|
||||
This creates a concise llms.txt quick reference plus a complete llms-full.txt command catalog.
|
||||
The output files will be written to the specified paths (defaults: ./llms.txt and ./llms-full.txt).`,
|
||||
Example: ` coolify docs llms
|
||||
coolify docs llms --output=./llms.txt`,
|
||||
coolify docs llms --output=./llms.txt --full-output=./llms-full.txt`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
fullOutputFile, _ := cmd.Flags().GetString("full-output")
|
||||
|
||||
var sb strings.Builder
|
||||
writeLLMsCommand(&sb, rootCmd, "coolify")
|
||||
|
||||
if err := os.WriteFile(outputFile, []byte(sb.String()), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms.txt: %w", err)
|
||||
}
|
||||
|
||||
absPath, _ := filepath.Abs(outputFile)
|
||||
fmt.Printf("llms.txt generated successfully: %s\n", absPath)
|
||||
|
||||
return nil
|
||||
return writeLLMsArtifacts(outputFile, fullOutputFile)
|
||||
},
|
||||
}
|
||||
|
||||
const llmsQuickTemplate = `# Coolify CLI - llms.txt
|
||||
|
||||
> Quick AI/LLM instructions for the Coolify CLI.
|
||||
> Source: https://github.com/coollabsio/coolify-cli
|
||||
> API Spec: https://github.com/coollabsio/coolify/blob/v4.x/openapi.json
|
||||
|
||||
## Operating Rules
|
||||
|
||||
- Prefer ` + "`--format json`" + ` for automation and parsing.
|
||||
- Use Coolify UUIDs for resources; do not use internal numeric IDs.
|
||||
- Team commands are the exception: they use numeric team IDs.
|
||||
- Authenticate with a saved context when possible; use ` + "`--token`" + ` only for overrides.
|
||||
- Use ` + "`llms-full.txt`" + ` for the exhaustive command/flag catalog.
|
||||
|
||||
## 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>`" + `
|
||||
4. Switch contexts with ` + "`coolify context use <context_name>`" + `
|
||||
|
||||
## 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
|
||||
|
||||
## Global Flags
|
||||
|
||||
- ` + "`--context <name>`" + ` - use a specific saved context
|
||||
- ` + "`--token <token>`" + ` - override token from config
|
||||
- ` + "`--format table|json|pretty`" + ` - choose output format
|
||||
- ` + "`--show-sensitive`" + ` - reveal sensitive values
|
||||
- ` + "`--debug`" + ` - enable debug output
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Contexts
|
||||
|
||||
` + "```bash" + `
|
||||
coolify context list
|
||||
coolify context verify
|
||||
coolify context version
|
||||
coolify context use prod
|
||||
` + "```" + `
|
||||
|
||||
### Inventory
|
||||
|
||||
` + "```bash" + `
|
||||
coolify server list
|
||||
coolify project list
|
||||
coolify resource list
|
||||
coolify app list
|
||||
coolify service list
|
||||
coolify database list
|
||||
` + "```" + `
|
||||
|
||||
### Applications
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app get <uuid>
|
||||
coolify app start <uuid>
|
||||
coolify app stop <uuid>
|
||||
coolify app restart <uuid>
|
||||
coolify app logs <uuid> --follow
|
||||
coolify app deployments list <app-uuid>
|
||||
coolify app deployments logs <app-uuid> --follow
|
||||
` + "```" + `
|
||||
|
||||
### Environment Variables
|
||||
|
||||
` + "```bash" + `
|
||||
coolify app env list <app-uuid>
|
||||
coolify app env create <app-uuid> --key API_KEY --value secret123
|
||||
coolify app env update <app-uuid> <env-uuid-or-key> --value new-secret
|
||||
coolify app env sync <app-uuid> --file .env.production --build-time --preview
|
||||
` + "```" + `
|
||||
|
||||
### Deployments
|
||||
|
||||
` + "```bash" + `
|
||||
coolify deploy list
|
||||
coolify deploy name my-application
|
||||
coolify deploy batch api,worker,frontend --force
|
||||
coolify deploy cancel <deployment-uuid>
|
||||
` + "```" + `
|
||||
|
||||
### Databases and Services
|
||||
|
||||
` + "```bash" + `
|
||||
coolify database get <uuid>
|
||||
coolify database create postgresql --server-uuid <uuid> --project-uuid <uuid> --environment-name production
|
||||
coolify database backup list <database-uuid>
|
||||
coolify service get <uuid>
|
||||
coolify service create <type> --project-uuid <uuid> --server-uuid <uuid> --instant-deploy
|
||||
` + "```" + `
|
||||
|
||||
## Common Aliases
|
||||
|
||||
- ` + "`coolify app`" + ` | ` + "`coolify apps`" + ` | ` + "`coolify application`" + ` | ` + "`coolify applications`" + `
|
||||
- ` + "`coolify service`" + ` | ` + "`coolify services`" + ` | ` + "`coolify svc`" + `
|
||||
- ` + "`coolify database`" + ` | ` + "`coolify databases`" + ` | ` + "`coolify db`" + ` | ` + "`coolify dbs`" + `
|
||||
- ` + "`coolify teams`" + ` | ` + "`coolify team`" + `
|
||||
|
||||
## Full Reference
|
||||
|
||||
- Full command and parameter catalog: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
`
|
||||
|
||||
const llmsFullIntro = `# Coolify CLI - llms-full.txt
|
||||
|
||||
> Full AI/LLM command catalog for the Coolify CLI.
|
||||
> 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
|
||||
|
||||
## Companion Files
|
||||
|
||||
- Quick instructions: %s
|
||||
- Regenerate docs: ` + "`go run ./coolify docs llms`" + `
|
||||
|
||||
## 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 llmsFullBody = `
|
||||
|
||||
## 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
|
||||
|
||||
`
|
||||
|
||||
func buildQuickLLMSText(fullReferencePath string) string {
|
||||
return fmt.Sprintf(llmsQuickTemplate, fullReferencePath)
|
||||
}
|
||||
|
||||
func buildFullLLMSText(quickReferencePath string) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, llmsFullIntro, quickReferencePath)
|
||||
writeLLMsAliases(&sb, rootCmd, "coolify")
|
||||
sb.WriteString(llmsFullBody)
|
||||
writeLLMsCommand(&sb, rootCmd, "coolify")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeLLMsArtifacts(outputFile, fullOutputFile string) error {
|
||||
if filepath.Clean(outputFile) == filepath.Clean(fullOutputFile) {
|
||||
return fmt.Errorf("output and full-output must be different files")
|
||||
}
|
||||
|
||||
if err := ensureParentDir(outputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureParentDir(fullOutputFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quickReferencePath := llmsReferencePath(fullOutputFile, outputFile)
|
||||
fullReferencePath := llmsReferencePath(outputFile, fullOutputFile)
|
||||
|
||||
if err := os.WriteFile(outputFile, []byte(buildQuickLLMSText(fullReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms.txt: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(fullOutputFile, []byte(buildFullLLMSText(quickReferencePath)), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write llms-full.txt: %w", err)
|
||||
}
|
||||
|
||||
absQuickPath, _ := filepath.Abs(outputFile)
|
||||
absFullPath, _ := filepath.Abs(fullOutputFile)
|
||||
fmt.Printf("llms.txt generated successfully: %s\n", absQuickPath)
|
||||
fmt.Printf("llms-full.txt generated successfully: %s\n", absFullPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureParentDir(path string) error {
|
||||
parentDir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(parentDir, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func llmsReferencePath(fromFile, toFile string) string {
|
||||
referencePath, err := filepath.Rel(filepath.Dir(fromFile), toFile)
|
||||
if err != nil {
|
||||
return filepath.ToSlash(toFile)
|
||||
}
|
||||
referencePath = filepath.ToSlash(referencePath)
|
||||
if strings.HasPrefix(referencePath, ".") || strings.HasPrefix(referencePath, "/") {
|
||||
return referencePath
|
||||
}
|
||||
return "./" + referencePath
|
||||
}
|
||||
|
||||
// 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 +572,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 +596,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)
|
||||
}
|
||||
@@ -235,6 +621,7 @@ func NewDocsCommand() *cobra.Command {
|
||||
manCmd.Flags().StringP("output-dir", "o", "./man", "Output directory for man pages")
|
||||
markdownCmd.Flags().StringP("output-dir", "o", "./docs", "Output directory for markdown files")
|
||||
llmsCmd.Flags().StringP("output", "o", "./llms.txt", "Output file path")
|
||||
llmsCmd.Flags().String("full-output", "./llms-full.txt", "Full output file path")
|
||||
|
||||
return docsCmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildQuickLLMSTextIncludesCoreGuidance(t *testing.T) {
|
||||
got := buildQuickLLMSText("./llms-full.txt")
|
||||
|
||||
for _, want := range []string{
|
||||
"# Coolify CLI - llms.txt",
|
||||
"Prefer `--format json` for automation and parsing.",
|
||||
"coolify context verify",
|
||||
"coolify app logs <uuid> --follow",
|
||||
"Full command and parameter catalog: ./llms-full.txt",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected quick llms output to contain %q\nfull output:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteLLMsArtifactsWritesQuickAndFullFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
quickPath := filepath.Join(tempDir, "llms.txt")
|
||||
fullPath := filepath.Join(tempDir, "nested", "llms-full.txt")
|
||||
|
||||
if err := writeLLMsArtifacts(quickPath, fullPath); err != nil {
|
||||
t.Fatalf("writeLLMsArtifacts() error = %v", err)
|
||||
}
|
||||
|
||||
quickContent, err := os.ReadFile(quickPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading quick file: %v", err)
|
||||
}
|
||||
fullContent, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed reading full file: %v", err)
|
||||
}
|
||||
|
||||
for _, want := range []struct {
|
||||
content string
|
||||
substr string
|
||||
}{
|
||||
{string(quickContent), "./nested/llms-full.txt"},
|
||||
{string(fullContent), "../llms.txt"},
|
||||
{string(fullContent), "## Command Reference"},
|
||||
} {
|
||||
if !strings.Contains(want.content, want.substr) {
|
||||
t.Fatalf("expected generated content to contain %q", want.substr)
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -32,7 +32,12 @@ func NewGetCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to get service: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter("table", output.Options{})
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
+6
-1
@@ -30,7 +30,12 @@ func NewListCommand() *cobra.Command {
|
||||
return fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
|
||||
formatter, err := output.NewFormatter("table", output.Options{})
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
|
||||
|
||||
formatter, err := output.NewFormatter(format, output.Options{
|
||||
ShowSensitive: showSensitive,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create formatter: %w", err)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2255
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user