Compare commits

...

12 Commits

Author SHA1 Message Date
Andras Bacsai c45a0b35ec feat(service): wire format and show-sensitive flags to get and list commands 2026-04-16 11:30:44 +02:00
Andras Bacsai 5e2b3d08db feat(docs): generate quick llms.txt and full llms-full.txt
Refactor `coolify docs llms` to emit two AI-oriented artifacts:
- `llms.txt` as a concise operating guide
- `llms-full.txt` as the exhaustive command and flag catalog

Update tests to cover quick/full generation, document both files in README,
and adjust CI to fail when either generated file is out of date.
2026-03-31 17:44:24 +02:00
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
19 changed files with 3174 additions and 2045 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]
+3 -3
View File
@@ -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
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"
+25
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
@@ -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-...
+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
}
+415 -28
View File
@@ -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
}
+118
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+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)
}
}
+2255
View File
File diff suppressed because it is too large Load Diff
+99 -1914
View File
File diff suppressed because it is too large Load Diff