Compare commits

...

43 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
Andras Bacsai f43cd16f6f Merge pull request #64 from coollabsio/coolify-cli-storage-endpoints
feat(storage): add CRUD operations for persistent and file storages
2026-03-23 14:44:30 +01:00
Andras Bacsai e49daeea95 Merge pull request #65 from coollabsio/62-app-env-update
feat(env): allow updating vars by UUID or key identifier
2026-03-23 14:44:19 +01:00
Andras Bacsai ccf578e537 docs(env): reorder parameters in env update commands
Reorganize parameter ordering for consistency across app, database, and
service env update commands. Move string parameters (--key, --value) after
boolean parameters. Simplify --key parameter description.
2026-03-23 14:41:03 +01:00
Andras Bacsai cad379eefb test(service): use assert.True/False helpers for boolean assertions
Replace assert.Equal with more specific boolean assertion helpers
(assert.True and assert.False) for improved readability and
idiomatic Go testing practices.
2026-03-23 14:40:55 +01:00
Andras Bacsai 53ab7b315c feat(storage): require minimum API version 4.0.0-beta.470
Add version check validation across all storage CRUD operations in application,
database, and service commands. This ensures the API client meets the minimum
version requirement before executing storage operations.

Also includes:
- Documentation updates for dockerfile-target-build parameter in llms.txt
- Field alignment formatting fixes in ApplicationCreateRequest structs
2026-03-23 14:38:19 +01:00
Andras Bacsai 8a7d2c20af Merge remote-tracking branch 'origin/v4.x' into coolify-cli-storage-endpoints 2026-03-23 14:32:49 +01:00
Andras Bacsai fcd1a01fb7 Merge remote-tracking branch 'origin/v4.x' into 62-app-env-update 2026-03-23 14:32:48 +01:00
Andras Bacsai f67411de2c feat(storage): add CRUD operations for persistent and file storages
Add comprehensive storage management system for applications, databases, and services:

- Implement storage subcommands (list, create, update, delete) with full API integration
- Add support for both persistent volumes and file-based storage management
- Create Storage model with comprehensive validation for type-specific operations
- Implement ApplicationService, DatabaseService, and ServiceService storage methods
- Add extensive unit tests covering CRUD operations and edge cases
- Integrate storage subcommand into application, database, and service CLI commands
- Add dockerfile-target-build flag support to application creation and update commands

Storage operations support:
- Persistent volumes: create with optional host paths, update mount paths and names
- File storages: create/update with content or file system paths, support directories
- Common features: mount path management, read-only detection, preview suffix toggling
2026-03-23 14:27:21 +01:00
Andras Bacsai 146ce7a7b0 feat(env): allow updating vars by UUID or key identifier
Add support for identifying environment variables by UUID or key name in
update commands, eliminating the requirement to use --key for lookups.
Commands now accept <env_uuid_or_key> as a positional argument to identify
the variable, with --key becoming optional for renaming only. Applied to
app, database, and service env update commands and updated documentation.
2026-03-23 11:16:47 +01:00
Andras Bacsai b661576fc1 Merge pull request #63 from coollabsio/61-expand-application-struct
feat(models): expand Application with extended configuration
2026-03-23 11:07:57 +01:00
Andras Bacsai 0872e48283 feat(models): add ApplicationSettings and expand Application with extended configuration
- Add ApplicationSettings struct for application-level feature flags
- Expand Application model with git, build, health check, and resource limit fields
- Add comprehensive unit tests for Application marshaling/unmarshaling
- Add application.json fixture for testing
2026-03-23 11:03:01 +01:00
Andras Bacsai 303fad333b Merge pull request #61 from Dagnan/fix_format_json_for_database_get_list
fix(database): respect --format flag in database list and get commands
2026-03-23 10:55:28 +01:00
Andras Bacsai 8ee7ec4c0d refactor(docs): use fmt.Fprintf and restrict file permissions to 0600
- Replace sb.WriteString(fmt.Sprintf(...)) with fmt.Fprintf for more idiomatic
  and efficient string formatting
- Tighten output file permissions from 0644 to 0600
- Move pflag to direct dependencies as it's now explicitly used
2026-03-23 10:46:11 +01:00
Andras Bacsai 98f40f03dc Merge pull request #52 from toanalien/feature/add-llms-config
feat: Add llms.txt for AI agent command specification
2026-03-23 10:31:48 +01:00
Andras Bacsai 28521a2ca0 ci: add llms.txt validation workflow job
Adds a new 'llms-txt' job to the test workflow that regenerates llms.txt
and validates it hasn't changed, ensuring the documentation stays in sync
with the CLI implementation.
2026-03-23 10:31:22 +01:00
Andras Bacsai dd4b271faf feat(docs): add llms command to generate machine-readable CLI spec
Add new `docs llms` subcommand that generates a machine-readable llms.txt
file defining all CLI commands and parameters. This enables AI agents to
understand and interact with the CLI programmatically.

- Implement writeLLMsCommand() to recursively document command hierarchy
- Regenerate llms.txt using the new generator for consistency
- Add --output flag to customize the output file path
2026-03-23 10:29:38 +01:00
Andras Bacsai cdc5a1e732 Merge pull request #50 from YaRissi/fix/app-env-sync
fix: app env bulk
2026-03-23 10:28:15 +01:00
Andras Bacsai 7e3639b41a Merge remote-tracking branch 'origin/v4.x' into feature/add-llms-config 2026-03-23 10:24:12 +01:00
Andras Bacsai 6bd783dc8a Merge remote-tracking branch 'origin/v4.x' into v4.x 2026-03-23 10:21:46 +01:00
Andras Bacsai 2ac1d0f869 chore: bump version to v1.5.0 2026-03-23 10:21:28 +01:00
Michel Pigassou f4c4c962ff fix(database): respect --format flag in list and get commands
The database list and get commands were hardcoded to use table format,
ignoring the global --format flag. This prevented users from using
--format=json or --format=pretty output formats.

Changes:
- cmd/database/list.go: read format flag from command
- cmd/database/get.go: read format flag from command
- Both commands now follow the same pattern as other list/get commands
2026-03-22 13:54:34 +01:00
Andras Bacsai 801c2e0b3c Merge pull request #55 from baer95/tablewriter
refactor: improve table output format
2026-03-20 18:14:18 +01:00
Andras Bacsai ea4bec7492 refactor(output): reorganize table formatter and improve test robustness
- Move empty slice check before table writer creation for early exit
- Fix error message typo: "ascii w" → "table"
- Enhance boolean test to dynamically locate data rows instead of hardcoded indices
2026-03-20 18:14:04 +01:00
Andras Bacsai 7e59cd76c3 feat(env): add database environment variable management
Add complete environment variable management for databases with full CRUD
operations, including a new sync command to load variables from .env files.
Support comment field across application, service, and database entities.

- Implement database env service layer with create, read, update, delete, list
- Add sync command for bulk loading and updating from .env files
- Add --comment flag to application and service env commands
- Include comprehensive test coverage for database service operations
- Update to Go 1.24.13
2026-03-20 17:31:42 +01:00
Andras Bacsai 81b9e9cdd0 test(application): fix linter warnings in BulkUpdateEnvs tests
Address unused function parameters and unhandled error return values.
2026-03-19 22:26:15 +01:00
Andras Bacsai fe01e8f9b8 test(application): verify BulkUpdateEnvs request serialization and error handling
- Add request body assertions to validate correct serialization in BulkUpdateEnvs test
- Add new test case for API error handling (500 response)
2026-03-19 22:08:26 +01:00
Andras Bacsai daa2a4cdcb Merge remote-tracking branch 'origin/v4.x' into fix/app-env-sync 2026-03-19 22:06:32 +01:00
Andras Bacsai 1703fd2e52 Merge pull request #60 from coollabsio/next
Next
2026-03-19 22:04:08 +01:00
Bernhard Frick 333ff3c504 refactor: improve table output format
replace text/tabwriter with olekukonko/tablewriter for nicer rendering of output formatted as tables
2026-01-26 21:52:03 +01:00
toanalien 0daae657fb feat: Add llms.txt for AI agent command specification
Introduces a machine-readable llms.txt file that defines all CLI commands and their parameters. This file is generated by analyzing the cobra command definitions and is intended to enable AI agents to understand and interact with the CLI.
2025-12-26 16:17:00 +07:00
YaRissi ea3236672b fixing app env sync 2025-12-19 18:09:25 +01:00
68 changed files with 6540 additions and 220 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]
+21 -1
View File
@@ -34,6 +34,26 @@ jobs:
- name: Run tests
run: go test -v -race -cover ./...
llms-txt:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- name: Regenerate llms.txt
run: go run ./coolify docs llms
- name: Check uncommitted changes
run: git diff --exit-code llms.txt llms-full.txt
- if: failure()
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
steps:
@@ -52,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"
+29 -4
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
@@ -149,9 +165,9 @@ Commands can use `server` or `servers` interchangeably.
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
- `coolify app env update <app_uuid>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `coolify app env update <app_uuid> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--preview` - Available in preview deployments
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
@@ -239,9 +255,9 @@ Commands can use `server` or `servers` interchangeably.
- `coolify service env get <service_uuid> <env_uuid_or_key>` - Get a specific environment variable
- `coolify service env create <service_uuid>` - Create a new environment variable
- Same flags as application environment variables
- `coolify service env update <service_uuid>` - Update an environment variable
- `--key <key>` - Variable key (required)
- `coolify service env update <service_uuid> <env_uuid_or_key>` - Update an environment variable
- `--value <value>` - Variable value (required)
- `--key <key>` - New variable key (optional, for renaming)
- `--build-time` - Available at build time
- `--is-literal` - Treat value as literal (don't interpolate variables)
- `--is-multiline` - Value is multiline
@@ -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-...
+14
View File
@@ -5,6 +5,7 @@ import (
"github.com/coollabsio/coolify-cli/cmd/application/create"
"github.com/coollabsio/coolify-cli/cmd/application/env"
"github.com/coollabsio/coolify-cli/cmd/application/storage"
)
// NewAppCommand creates the app parent command
@@ -43,5 +44,18 @@ func NewAppCommand() *cobra.Command {
envCmd.AddCommand(env.NewSyncEnvCommand())
cmd.AddCommand(envCmd)
// Add storage subcommand with its children
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage application storages",
Long: `List and manage persistent volumes and file storages for applications.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+2
View File
@@ -93,6 +93,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -147,6 +148,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -70,6 +70,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -115,6 +116,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -76,6 +76,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -122,6 +123,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -94,6 +94,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -148,6 +149,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+2
View File
@@ -85,6 +85,7 @@ Examples:
setOptionalBoolFlag(cmd, "instant-deploy", &req.InstantDeploy)
setOptionalBoolFlag(cmd, "health-check-enabled", &req.HealthCheckEnabled)
setOptionalStringFlag(cmd, "health-check-path", &req.HealthCheckPath)
setOptionalStringFlag(cmd, "dockerfile-target-build", &req.DockerfileTargetBuild)
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -138,6 +139,7 @@ Examples:
cmd.Flags().String("limits-memory", "", "Memory limit")
cmd.Flags().Bool("health-check-enabled", false, "Enable health checks")
cmd.Flags().String("health-check-path", "", "Health check path")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
return cmd
}
+6 -1
View File
@@ -60,6 +60,10 @@ func NewCreateEnvCommand() *cobra.Command {
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.CreateEnv(ctx, appUUID, req)
@@ -67,7 +71,7 @@ func NewCreateEnvCommand() *cobra.Command {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
fmt.Printf("Environment variable '%s' created successfully.\n", key)
fmt.Printf("UUID: %s\n", env.UUID)
return nil
},
@@ -80,5 +84,6 @@ func NewCreateEnvCommand() *cobra.Command {
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+23 -9
View File
@@ -12,13 +12,14 @@ import (
func NewUpdateEnvCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Use: "update <app_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. UUID is the application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<app_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
appUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -30,12 +31,24 @@ func NewUpdateEnvCommand() *cobra.Command {
return err
}
appSvc := service.NewApplicationService(client)
// Look up the env var to resolve its key
existingEnv, err := appSvc.GetEnv(ctx, appUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.EnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
@@ -60,15 +73,15 @@ func NewUpdateEnvCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil {
return fmt.Errorf("--key is required")
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
appSvc := service.NewApplicationService(client)
env, err := appSvc.UpdateEnv(ctx, appUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
@@ -79,12 +92,13 @@ func NewUpdateEnvCommand() *cobra.Command {
},
}
cmd.Flags().String("key", "", "New environment variable key")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("preview", false, "Available in preview deployments")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+96
View File
@@ -0,0 +1,96 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCreateCommand returns the storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <app_uuid>",
Short: "Create a storage for an application",
Long: `Create a persistent volume or file storage for an application.
Examples:
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage create <app_uuid> --type persistent --name my-volume --mount-path /data --host-path /var/data
coolify app storage create <app_uuid> --type file --mount-path /app/config.yml --content "key: value"
coolify app storage create <app_uuid> --type file --mount-path /app/data --is-directory --fs-path /app/data`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
req := &models.StorageCreateRequest{
Type: storageType,
MountPath: mountPath,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <app_uuid> <storage_uuid>",
Short: "Delete a storage from an application",
Long: `Delete a persistent volume or file storage from an application.
Examples:
coolify app storage delete <app_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<app_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <app_uuid>",
Short: "List all storages for an application",
Long: `List all persistent volumes and file storages for a specific application.`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
storages, err := appSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+117
View File
@@ -0,0 +1,117 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewUpdateCommand returns the storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <app_uuid>",
Short: "Update a storage for an application",
Long: `Update a persistent volume or file storage for an application.
The --uuid and --type flags are required. Use 'coolify app storage list' to find storage UUIDs.
For read-only storages (from docker-compose or services), only --is-preview-suffix-enabled can be updated.
Examples:
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --name my-volume --mount-path /data
coolify app storage update <app_uuid> --uuid <storage_uuid> --type file --content "config content" --mount-path /app/config.yml
coolify app storage update <app_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<app_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
appSvc := service.NewApplicationService(client)
if err := appSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+6
View File
@@ -104,6 +104,11 @@ func NewUpdateCommand() *cobra.Command {
req.PortsMappings = &ports
hasUpdates = true
}
if cmd.Flags().Changed("dockerfile-target-build") {
targetBuild, _ := cmd.Flags().GetString("dockerfile-target-build")
req.DockerfileTargetBuild = &targetBuild
hasUpdates = true
}
if cmd.Flags().Changed("health-check-enabled") {
enabled, _ := cmd.Flags().GetBool("health-check-enabled")
req.HealthCheckEnabled = &enabled
@@ -152,6 +157,7 @@ func NewUpdateCommand() *cobra.Command {
cmd.Flags().String("dockerfile", "", "Dockerfile content")
cmd.Flags().String("docker-image", "", "Docker image name")
cmd.Flags().String("docker-tag", "", "Docker image tag")
cmd.Flags().String("dockerfile-target-build", "", "Dockerfile target build stage")
cmd.Flags().String("ports-exposes", "", "Exposed ports")
cmd.Flags().String("ports-mappings", "", "Port mappings")
cmd.Flags().Bool("health-check-enabled", false, "Enable health check")
+28
View File
@@ -4,6 +4,8 @@ import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/database/backup"
"github.com/coollabsio/coolify-cli/cmd/database/env"
"github.com/coollabsio/coolify-cli/cmd/database/storage"
)
// NewDatabaseCommand creates the database parent command with all subcommands
@@ -25,6 +27,19 @@ func NewDatabaseCommand() *cobra.Command {
cmd.AddCommand(NewUpdateCommand())
cmd.AddCommand(NewDeleteCommand())
// Add env subcommand
envCmd := &cobra.Command{
Use: "env",
Short: "Manage database environment variables",
}
envCmd.AddCommand(env.NewListCommand())
envCmd.AddCommand(env.NewGetCommand())
envCmd.AddCommand(env.NewCreateCommand())
envCmd.AddCommand(env.NewUpdateCommand())
envCmd.AddCommand(env.NewDeleteCommand())
envCmd.AddCommand(env.NewSyncCommand())
cmd.AddCommand(envCmd)
// Add backup subcommand
backupCmd := &cobra.Command{
Use: "backup",
@@ -39,5 +54,18 @@ func NewDatabaseCommand() *cobra.Command {
backupCmd.AddCommand(backup.NewDeleteExecutionCommand())
cmd.AddCommand(backupCmd)
// Add storage subcommand
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage database storages",
Long: `List and manage persistent volumes and file storages for databases.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+79
View File
@@ -0,0 +1,79 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <database_uuid>",
Short: "Create an environment variable for a database",
Long: `Create a new environment variable for a specific database. Use --key and --value flags to specify the variable.`,
Args: cli.ExactArgs(1, "<database_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
key, _ := cmd.Flags().GetString("key")
value, _ := cmd.Flags().GetString("value")
if key == "" {
return fmt.Errorf("--key is required")
}
if value == "" {
return fmt.Errorf("--value is required")
}
req := &models.DatabaseEnvironmentVariableCreateRequest{
Key: key,
Value: value,
}
if cmd.Flags().Changed("is-literal") {
isLiteral, _ := cmd.Flags().GetBool("is-literal")
req.IsLiteral = &isLiteral
}
if cmd.Flags().Changed("is-multiline") {
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
req.IsMultiline = &isMultiline
}
if cmd.Flags().Changed("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
dbSvc := service.NewDatabaseService(client)
_, err = dbSvc.CreateEnv(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", key)
return nil
},
}
cmd.Flags().String("key", "", "Environment variable key (required)")
cmd.Flags().String("value", "", "Environment variable value (required)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+56
View File
@@ -0,0 +1,56 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <database_uuid> <env_uuid>",
Short: "Delete an environment variable",
Long: `Delete an environment variable from a database. First UUID is the database, second is the specific environment variable to delete.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
force, _ := cmd.Flags().GetBool("force")
// Prompt for confirmation unless --force is used
if !force {
var response string
fmt.Printf("Are you sure you want to delete this environment variable? (yes/no): ")
_, _ = fmt.Scanln(&response)
if response != "yes" && response != "y" {
fmt.Println("Delete cancelled.")
return nil
}
}
dbSvc := service.NewDatabaseService(client)
err = dbSvc.DeleteEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to delete environment variable: %w", err)
}
fmt.Println("Environment variable deleted successfully.")
return nil
},
}
cmd.Flags().Bool("force", false, "Skip confirmation prompt")
return cmd
}
+57
View File
@@ -0,0 +1,57 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewGetCommand() *cobra.Command {
return &cobra.Command{
Use: "get <database_uuid> <env_uuid_or_key>",
Short: "Get environment variable details",
Long: `Get detailed information about a specific environment variable. First UUID is the database, second is the environment variable UUID or key name.`,
Args: cli.ExactArgs(2, "<uuid1> <uuid2>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envUUID := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbSvc := service.NewDatabaseService(client)
env, err := dbSvc.GetEnv(ctx, dbUUID, envUUID)
if err != nil {
return fmt.Errorf("failed to get environment variable: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive value unless --show-sensitive is used
if !showSensitive {
env.Value = "********"
if env.RealValue != nil {
masked := "********"
env.RealValue = &masked
}
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(env)
},
}
}
+58
View File
@@ -0,0 +1,58 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <database_uuid>",
Short: "List all environment variables for a database",
Long: `List all environment variables for a specific database.`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
dbSvc := service.NewDatabaseService(client)
envs, err := dbSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list environment variables: %w", err)
}
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
// Mask sensitive values unless --show-sensitive is used
if !showSensitive {
for i := range envs {
envs[i].Value = "********"
if envs[i].RealValue != nil {
masked := "********"
envs[i].RealValue = &masked
}
}
}
format, _ := cmd.Flags().GetString("format")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(envs)
},
}
}
+145
View File
@@ -0,0 +1,145 @@
package env
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/parser"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewSyncCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "sync <database_uuid>",
Short: "Sync environment variables from a .env file",
Long: `Sync environment variables from a .env file. This command intelligently:
- Updates existing environment variables with new values
- Creates new environment variables that don't exist yet
- Uses efficient bulk operations where possible
Example: coolify db env sync abc123 --file .env.production`,
Args: cli.ExactArgs(1, "<uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
uuid := args[0]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
filePath, _ := cmd.Flags().GetString("file")
if filePath == "" {
return fmt.Errorf("--file is required")
}
isLiteral, _ := cmd.Flags().GetBool("is-literal")
// Parse the .env file
envVars, err := parser.ParseEnvFile(filePath)
if err != nil {
return fmt.Errorf("failed to parse .env file: %w", err)
}
if len(envVars) == 0 {
fmt.Println("No environment variables found in file.")
return nil
}
fmt.Printf("Found %d environment variables in file. Syncing...\n", len(envVars))
// Fetch existing environment variables
dbSvc := service.NewDatabaseService(client)
existingEnvs, err := dbSvc.ListEnvs(ctx, uuid)
if err != nil {
return fmt.Errorf("failed to list existing environment variables: %w", err)
}
// Build a map of existing env vars by key
existingMap := make(map[string]models.DatabaseEnvironmentVariable)
for _, env := range existingEnvs {
existingMap[env.Key] = env
}
// Separate into updates and creates
var toUpdate []models.DatabaseEnvironmentVariableCreateRequest
var toCreate []models.DatabaseEnvironmentVariableCreateRequest
for _, envVar := range envVars {
req := models.DatabaseEnvironmentVariableCreateRequest{
Key: envVar.Key,
Value: envVar.Value,
}
// Apply flags if explicitly provided
if cmd.Flags().Changed("is-literal") {
req.IsLiteral = &isLiteral
}
// Auto-detect multiline values
if strings.Contains(envVar.Value, "\n") {
multiline := true
req.IsMultiline = &multiline
}
if _, exists := existingMap[envVar.Key]; exists {
toUpdate = append(toUpdate, req)
} else {
toCreate = append(toCreate, req)
}
}
updateCount := 0
createCount := 0
failCount := 0
// Perform bulk update if there are vars to update
if len(toUpdate) > 0 {
fmt.Printf("Updating %d existing variables...\n", len(toUpdate))
bulkReq := &models.DatabaseEnvBulkUpdateRequest{
Data: toUpdate,
}
_, err := dbSvc.BulkUpdateEnvs(ctx, uuid, bulkReq)
if err != nil {
fmt.Printf(" ✗ Bulk update failed: %v\n", err)
failCount += len(toUpdate)
} else {
updateCount = len(toUpdate)
fmt.Printf(" ✓ Successfully updated %d variables\n", updateCount)
}
}
// Create new variables one by one
if len(toCreate) > 0 {
fmt.Printf("Creating %d new variables...\n", len(toCreate))
for _, req := range toCreate {
_, err := dbSvc.CreateEnv(ctx, uuid, &req)
if err != nil {
fmt.Printf(" ✗ Failed to create '%s': %v\n", req.Key, err)
failCount++
} else {
fmt.Printf(" ✓ Created '%s'\n", req.Key)
createCount++
}
}
}
fmt.Printf("\nSync complete: %d updated, %d created, %d failed\n", updateCount, createCount, failCount)
if failCount > 0 {
return fmt.Errorf("some environment variables failed to sync")
}
return nil
},
}
cmd.Flags().StringP("file", "f", "", "Path to .env file (required)")
cmd.Flags().Bool("is-literal", false, "Treat all values as literal (don't interpolate variables)")
return cmd
}
+95
View File
@@ -0,0 +1,95 @@
package env
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <database_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<database_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
dbUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
// Check minimum version requirement
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.469"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
// Look up the env var to resolve its key
existingEnv, err := dbSvc.GetEnv(ctx, dbUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.DatabaseEnvironmentVariableUpdateRequest{}
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
}
if cmd.Flags().Changed("is-literal") {
isLiteral, _ := cmd.Flags().GetBool("is-literal")
req.IsLiteral = &isLiteral
}
if cmd.Flags().Changed("is-multiline") {
isMultiline, _ := cmd.Flags().GetBool("is-multiline")
req.IsMultiline = &isMultiline
}
if cmd.Flags().Changed("is-shown-once") {
isShownOnce, _ := cmd.Flags().GetBool("is-shown-once")
req.IsShownOnce = &isShownOnce
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
env, err := dbSvc.UpdateEnv(ctx, dbUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' updated successfully.\n", env.Key)
return nil
},
}
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("is-shown-once", false, "Only show value once")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+12 -2
View File
@@ -32,15 +32,25 @@ func NewGetCommand() *cobra.Command {
return fmt.Errorf("failed to get database: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter("table", output.Options{
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return fmt.Errorf("failed to create formatter: %w", err)
}
return formatter.Format(database)
if err := formatter.Format(database); err != nil {
return err
}
if !showSensitive && format == output.FormatTable {
fmt.Println("\nNote: Use -s to show sensitive information.")
}
return nil
},
}
}
+15 -2
View File
@@ -30,12 +30,25 @@ func NewListCommand() *cobra.Command {
return fmt.Errorf("failed to list databases: %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)
}
return formatter.Format(databases)
if err := formatter.Format(databases); err != nil {
return err
}
if !showSensitive && format == output.FormatTable {
fmt.Println("\nNote: Use -s to show sensitive information.")
}
return nil
},
}
}
+94
View File
@@ -0,0 +1,94 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCreateCommand returns the database storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <db_uuid>",
Short: "Create a storage for a database",
Long: `Create a persistent volume or file storage for a database.
Examples:
coolify db storage create <db_uuid> --type persistent --name my-volume --mount-path /data
coolify db storage create <db_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
req := &models.StorageCreateRequest{
Type: storageType,
MountPath: mountPath,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the database storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <db_uuid> <storage_uuid>",
Short: "Delete a storage from a database",
Long: `Delete a persistent volume or file storage from a database.
Examples:
coolify db storage delete <db_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<db_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the database storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <db_uuid>",
Short: "List all storages for a database",
Long: `List all persistent volumes and file storages for a specific database.`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
storages, err := dbSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+114
View File
@@ -0,0 +1,114 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewUpdateCommand returns the database storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <db_uuid>",
Short: "Update a storage for a database",
Long: `Update a persistent volume or file storage for a database.
The --uuid and --type flags are required. Use 'coolify db storage list' to find storage UUIDs.
Examples:
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --name my-volume
coolify db storage update <db_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<db_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
dbSvc := service.NewDatabaseService(client)
if err := dbSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+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
}
+531
View File
@@ -4,9 +4,12 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
"github.com/spf13/pflag"
)
var docsCmd = &cobra.Command{
@@ -85,12 +88,540 @@ The markdown files will be written to the specified directory (default: ./docs).
},
}
var llmsCmd = &cobra.Command{
Use: "llms",
Short: "Generate llms.txt and llms-full.txt for AI agents",
Long: `Generate AI-friendly documentation files for the Coolify CLI.
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 --full-output=./llms-full.txt`,
RunE: func(cmd *cobra.Command, _ []string) error {
outputFile, _ := cmd.Flags().GetString("output")
fullOutputFile, _ := cmd.Flags().GetString("full-output")
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 := llmsCommandName(parentPath, cmd)
// Skip the docs command itself and help command
if cmd.Name() == "docs" || cmd.Name() == "help" {
return
}
// Determine if this command should be written
isRoot := !cmd.HasParent()
isRunnable := cmd.RunE != nil || cmd.Run != nil
hasVisibleChildren := false
for _, child := range cmd.Commands() {
if !child.Hidden && child.Name() != "help" {
hasVisibleChildren = true
break
}
}
// Write the root command, runnable commands, and leaf commands (no children)
if isRoot || isRunnable || !hasVisibleChildren {
// Get description - prefer Long if it's a single clean sentence, otherwise use Short
description := cmd.Short
if cmd.Long != "" {
longLines := strings.Split(strings.TrimSpace(cmd.Long), "\n")
if len(longLines) == 1 && len(longLines[0]) < 200 {
description = longLines[0]
}
}
fmt.Fprintf(sb, "Command: %s\n", commandPath)
fmt.Fprintf(sb, "Description: %s\n", description)
// For root command, show persistent flags; for others, show local flags
var flags []*pflag.Flag
if isRoot {
cmd.PersistentFlags().VisitAll(func(f *pflag.Flag) {
if f.Name == "help" {
return
}
flags = append(flags, f)
})
} else {
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
if f.Name == "help" {
return
}
flags = append(flags, f)
})
}
if len(flags) == 0 {
sb.WriteString("Parameters: (None)\n")
} else {
sb.WriteString("Parameters:\n")
for _, f := range flags {
flagType := f.Value.Type()
// Normalize type names
switch flagType {
case "int", "int32", "int64":
flagType = "integer"
case "bool":
flagType = "boolean"
}
// Check if the flag is marked as required via cobra annotation
// or via "(required)" in the usage string
required := isFlagRequired(f)
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)
}
}
}
sb.WriteString("\n")
}
// Recurse into subcommands
for _, child := range cmd.Commands() {
if child.Hidden || child.Name() == "help" {
continue
}
childPath := parentPath
if cmd.HasParent() {
childPath = llmsCommandName(parentPath, cmd)
}
writeLLMsCommand(sb, child, childPath)
}
}
// isFlagRequired checks if a flag is required by looking at cobra annotations
// and the "(required)" convention in usage strings.
func isFlagRequired(f *pflag.Flag) bool {
// Check cobra's MarkFlagRequired annotation
if ann, ok := f.Annotations[cobra.BashCompOneRequiredFlag]; ok && len(ann) > 0 && ann[0] == "true" {
return true
}
// Check for "(required)" in usage string (convention used in this codebase)
return strings.Contains(strings.ToLower(f.Usage), "(required)")
}
func NewDocsCommand() *cobra.Command {
docsCmd.AddCommand(manCmd)
docsCmd.AddCommand(markdownCmd)
docsCmd.AddCommand(llmsCmd)
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)
}
}
}
+7 -2
View File
@@ -57,14 +57,18 @@ func NewCreateCommand() *cobra.Command {
if cmd.Flags().Changed("runtime") {
req.IsRuntime = &isRuntime
}
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
serviceSvc := service.NewService(client)
env, err := serviceSvc.CreateEnv(ctx, uuid, req)
_, err = serviceSvc.CreateEnv(ctx, uuid, req)
if err != nil {
return fmt.Errorf("failed to create environment variable: %w", err)
}
fmt.Printf("Environment variable '%s' created successfully.\n", env.Key)
fmt.Printf("Environment variable '%s' created successfully.\n", key)
return nil
},
}
@@ -75,6 +79,7 @@ func NewCreateCommand() *cobra.Command {
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+23 -10
View File
@@ -12,13 +12,14 @@ import (
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <service_uuid>",
Use: "update <service_uuid> <env_uuid_or_key>",
Short: "Update an environment variable",
Long: `Update an existing environment variable. UUID is the service.`,
Args: cli.ExactArgs(1, "<service_uuid>"),
Long: `Update an existing environment variable. Identify it by UUID or key name.`,
Args: cli.ExactArgs(2, "<service_uuid> <env_uuid_or_key>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
serviceUUID := args[0]
envIdentifier := args[1]
client, err := cli.GetAPIClient(cmd)
if err != nil {
@@ -30,13 +31,24 @@ func NewUpdateCommand() *cobra.Command {
return err
}
serviceSvc := service.NewService(client)
// Look up the env var to resolve its key
existingEnv, err := serviceSvc.GetEnv(ctx, serviceUUID, envIdentifier)
if err != nil {
return fmt.Errorf("failed to find environment variable '%s': %w", envIdentifier, err)
}
req := &models.ServiceEnvironmentVariableUpdateRequest{}
// Only set fields that were provided
// Use existing key unless --key flag explicitly provides a new one
if cmd.Flags().Changed("key") {
key, _ := cmd.Flags().GetString("key")
req.Key = &key
} else {
req.Key = &existingEnv.Key
}
if cmd.Flags().Changed("value") {
value, _ := cmd.Flags().GetString("value")
req.Value = &value
@@ -57,15 +69,15 @@ func NewUpdateCommand() *cobra.Command {
isRuntime, _ := cmd.Flags().GetBool("runtime")
req.IsRuntime = &isRuntime
}
if req.Key == nil {
return fmt.Errorf("--key is required")
if cmd.Flags().Changed("comment") {
comment, _ := cmd.Flags().GetString("comment")
req.Comment = &comment
}
if req.Value == nil {
return fmt.Errorf("--value is required")
}
serviceSvc := service.NewService(client)
env, err := serviceSvc.UpdateEnv(ctx, serviceUUID, req)
if err != nil {
return fmt.Errorf("failed to update environment variable: %w", err)
@@ -76,12 +88,13 @@ func NewUpdateCommand() *cobra.Command {
},
}
cmd.Flags().String("key", "", "New environment variable key")
cmd.Flags().String("value", "", "New environment variable value")
cmd.Flags().String("key", "", "New environment variable key (rename)")
cmd.Flags().String("value", "", "New environment variable value (required)")
cmd.Flags().Bool("build-time", true, "Available at build time (default: true)")
cmd.Flags().Bool("is-literal", false, "Treat value as literal (don't interpolate variables)")
cmd.Flags().Bool("is-multiline", false, "Value is multiline")
cmd.Flags().Bool("runtime", true, "Available at runtime (default: true)")
cmd.Flags().String("comment", "", "Comment for the environment variable")
return cmd
}
+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)
}
+14
View File
@@ -4,6 +4,7 @@ import (
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/cmd/service/env"
"github.com/coollabsio/coolify-cli/cmd/service/storage"
)
// NewServiceCommand creates the service parent command with all subcommands
@@ -37,5 +38,18 @@ func NewServiceCommand() *cobra.Command {
envCmd.AddCommand(env.NewSyncCommand())
cmd.AddCommand(envCmd)
// Add storage subcommand
storageCmd := &cobra.Command{
Use: "storage",
Aliases: []string{"storages"},
Short: "Manage service storages",
Long: `List and manage persistent volumes and file storages for services.`,
}
storageCmd.AddCommand(storage.NewListCommand())
storageCmd.AddCommand(storage.NewCreateCommand())
storageCmd.AddCommand(storage.NewUpdateCommand())
storageCmd.AddCommand(storage.NewDeleteCommand())
cmd.AddCommand(storageCmd)
return cmd
}
+103
View File
@@ -0,0 +1,103 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewCreateCommand returns the service storage create command
func NewCreateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "create <service_uuid>",
Short: "Create a storage for a service",
Long: `Create a persistent volume or file storage for a service.
The --resource-uuid flag is required to specify which service sub-resource
(application or database) the storage belongs to.
Examples:
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type persistent --name my-volume --mount-path /data
coolify svc storage create <service_uuid> --resource-uuid <sub_resource_uuid> --type file --mount-path /app/config.yml --content "key: value"`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageType, _ := cmd.Flags().GetString("type")
mountPath, _ := cmd.Flags().GetString("mount-path")
resourceUUID, _ := cmd.Flags().GetString("resource-uuid")
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
if mountPath == "" {
return fmt.Errorf("--mount-path is required")
}
if resourceUUID == "" {
return fmt.Errorf("--resource-uuid is required (UUID of the service sub-resource)")
}
req := &models.ServiceStorageCreateRequest{
Type: storageType,
MountPath: mountPath,
ResourceUUID: resourceUUID,
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
}
if cmd.Flags().Changed("is-directory") {
val, _ := cmd.Flags().GetBool("is-directory")
req.IsDirectory = &val
}
if cmd.Flags().Changed("fs-path") {
val, _ := cmd.Flags().GetString("fs-path")
req.FsPath = &val
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.CreateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
fmt.Println("Storage created successfully.")
return nil
},
}
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().String("mount-path", "", "Mount path inside the container (required)")
cmd.Flags().String("resource-uuid", "", "UUID of the service sub-resource (required)")
cmd.Flags().String("name", "", "Volume name (persistent only)")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
cmd.Flags().Bool("is-directory", false, "Whether this is a directory mount (file only)")
cmd.Flags().String("fs-path", "", "Host directory path (file only, required when --is-directory is set)")
return cmd
}
+43
View File
@@ -0,0 +1,43 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewDeleteCommand returns the service storage delete command
func NewDeleteCommand() *cobra.Command {
return &cobra.Command{
Use: "delete <service_uuid> <storage_uuid>",
Short: "Delete a storage from a service",
Long: `Delete a persistent volume or file storage from a service.
Examples:
coolify svc storage delete <service_uuid> <storage_uuid>`,
Args: cli.ExactArgs(2, "<service_uuid> <storage_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.DeleteStorage(ctx, args[0], args[1]); err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
fmt.Println("Storage deleted successfully.")
return nil
},
}
}
+51
View File
@@ -0,0 +1,51 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/output"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewListCommand returns the service storage list command
func NewListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <service_uuid>",
Short: "List all storages for a service",
Long: `List all persistent volumes and file storages for a specific service.`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
storages, err := svcSvc.ListStorages(ctx, args[0])
if err != nil {
return fmt.Errorf("failed to list storages: %w", err)
}
format, _ := cmd.Flags().GetString("format")
showSensitive, _ := cmd.Flags().GetBool("show-sensitive")
formatter, err := output.NewFormatter(format, output.Options{
ShowSensitive: showSensitive,
})
if err != nil {
return err
}
return formatter.Format(storages)
},
}
}
+114
View File
@@ -0,0 +1,114 @@
package storage
import (
"fmt"
"github.com/spf13/cobra"
"github.com/coollabsio/coolify-cli/internal/cli"
"github.com/coollabsio/coolify-cli/internal/models"
"github.com/coollabsio/coolify-cli/internal/service"
)
// NewUpdateCommand returns the service storage update command
func NewUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update <service_uuid>",
Short: "Update a storage for a service",
Long: `Update a persistent volume or file storage for a service.
The --uuid and --type flags are required. Use 'coolify svc storage list' to find storage UUIDs.
Examples:
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --name my-volume
coolify svc storage update <service_uuid> --uuid <storage_uuid> --type persistent --is-preview-suffix-enabled`,
Args: cli.ExactArgs(1, "<service_uuid>"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
storageUUID, _ := cmd.Flags().GetString("uuid")
storageID, _ := cmd.Flags().GetInt("id")
storageType, _ := cmd.Flags().GetString("type")
if storageUUID == "" && storageID == 0 {
return fmt.Errorf("--uuid is required (or --id as deprecated fallback)")
}
if storageType == "" {
return fmt.Errorf("--type is required (persistent or file)")
}
if storageType != "persistent" && storageType != "file" {
return fmt.Errorf("--type must be 'persistent' or 'file'")
}
req := &models.StorageUpdateRequest{
Type: storageType,
}
if storageUUID != "" {
req.UUID = &storageUUID
} else {
req.ID = &storageID
}
hasUpdates := false
if cmd.Flags().Changed("is-preview-suffix-enabled") {
val, _ := cmd.Flags().GetBool("is-preview-suffix-enabled")
req.IsPreviewSuffixEnabled = &val
hasUpdates = true
}
if cmd.Flags().Changed("name") {
val, _ := cmd.Flags().GetString("name")
req.Name = &val
hasUpdates = true
}
if cmd.Flags().Changed("mount-path") {
val, _ := cmd.Flags().GetString("mount-path")
req.MountPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("host-path") {
val, _ := cmd.Flags().GetString("host-path")
req.HostPath = &val
hasUpdates = true
}
if cmd.Flags().Changed("content") {
val, _ := cmd.Flags().GetString("content")
req.Content = &val
hasUpdates = true
}
if !hasUpdates {
return fmt.Errorf("no fields to update. Use --help to see available flags")
}
client, err := cli.GetAPIClient(cmd)
if err != nil {
return fmt.Errorf("failed to get API client: %w", err)
}
if err := cli.CheckMinimumVersion(ctx, client, "4.0.0-beta.470"); err != nil {
return err
}
svcSvc := service.NewService(client)
if err := svcSvc.UpdateStorage(ctx, args[0], req); err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
fmt.Println("Storage updated successfully.")
return nil
},
}
cmd.Flags().String("uuid", "", "Storage UUID (required, use 'storage list' to find)")
cmd.Flags().Int("id", 0, "Storage ID (deprecated, use --uuid instead)")
cmd.Flags().String("type", "", "Storage type: 'persistent' or 'file' (required)")
cmd.Flags().Bool("is-preview-suffix-enabled", false, "Enable preview suffix for this storage")
cmd.Flags().String("name", "", "Storage name (persistent only)")
cmd.Flags().String("mount-path", "", "Mount path inside the container")
cmd.Flags().String("host-path", "", "Host path (persistent only)")
cmd.Flags().String("content", "", "File content (file only)")
return cmd
}
+13 -2
View File
@@ -1,12 +1,14 @@
module github.com/coollabsio/coolify-cli
go 1.24.6
go 1.24.13
require (
github.com/adrg/xdg v0.5.3
github.com/creativeprojects/go-selfupdate v1.5.1
github.com/hashicorp/go-version v1.7.0
github.com/olekukonko/tablewriter v1.1.2
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
)
@@ -15,9 +17,13 @@ require (
code.gitea.io/sdk/gitea v0.22.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/clipperhouse/displaywidth v0.6.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -26,13 +32,18 @@ require (
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.1.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/go-gitlab v0.115.0 // indirect
+19
View File
@@ -6,6 +6,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -50,8 +56,19 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg=
github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc=
github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
@@ -97,6 +114,8 @@ golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+249 -16
View File
@@ -1,16 +1,116 @@
package models
// ApplicationSettings represents the settings for a Coolify application
type ApplicationSettings struct {
ID int `json:"-" table:"-"`
ApplicationID int `json:"-" table:"-"`
IsStatic *bool `json:"is_static,omitempty"`
IsBuildServerEnabled *bool `json:"is_build_server_enabled,omitempty"`
IsPreserveRepositoryEnabled *bool `json:"is_preserve_repository_enabled,omitempty"`
IsAutoDeployEnabled *bool `json:"is_auto_deploy_enabled,omitempty"`
IsForceHTTPSEnabled *bool `json:"is_force_https_enabled,omitempty"`
IsDebugEnabled *bool `json:"is_debug_enabled,omitempty"`
IsPreviewDeploymentsEnabled *bool `json:"is_preview_deployments_enabled,omitempty"`
IsGitSubmodulesEnabled *bool `json:"is_git_submodules_enabled,omitempty"`
IsGitLFSEnabled *bool `json:"is_git_lfs_enabled,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// Application represents a Coolify application
type Application struct {
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
GitBranch *string `json:"git_branch,omitempty"`
FQDN *string `json:"fqdn,omitempty"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
// Table-visible fields
UUID string `json:"uuid"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Status string `json:"status"`
FQDN *string `json:"fqdn,omitempty"`
GitRepository *string `json:"git_repository,omitempty"`
GitBranch *string `json:"git_branch,omitempty"`
BuildPack *string `json:"build_pack,omitempty"`
PortsExposes *string `json:"ports_exposes,omitempty"`
// Git extended (JSON-only)
GitCommitSHA *string `json:"git_commit_sha,omitempty" table:"-"`
GitFullURL *string `json:"git_full_url,omitempty" table:"-"`
// Build configuration (JSON-only)
InstallCommand *string `json:"install_command,omitempty" table:"-"`
BuildCommand *string `json:"build_command,omitempty" table:"-"`
StartCommand *string `json:"start_command,omitempty" table:"-"`
BaseDirectory *string `json:"base_directory,omitempty" table:"-"`
PublishDirectory *string `json:"publish_directory,omitempty" table:"-"`
StaticImage *string `json:"static_image,omitempty" table:"-"`
// Docker configuration (JSON-only)
Dockerfile *string `json:"dockerfile,omitempty" table:"-"`
DockerfileLocation *string `json:"dockerfile_location,omitempty" table:"-"`
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty" table:"-"`
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty" table:"-"`
DockerCompose *string `json:"docker_compose,omitempty" table:"-"`
DockerComposeRaw *string `json:"docker_compose_raw,omitempty" table:"-"`
DockerComposeLocation *string `json:"docker_compose_location,omitempty" table:"-"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty" table:"-"`
CustomLabels *string `json:"custom_labels,omitempty" table:"-"`
CustomNginxConfiguration *string `json:"custom_nginx_configuration,omitempty" table:"-"`
// Networking (JSON-only)
PortsMappings *string `json:"ports_mappings,omitempty" table:"-"`
Domains *string `json:"domains,omitempty" table:"-"`
Redirect *string `json:"redirect,omitempty" table:"-"`
PreviewURLTemplate *string `json:"preview_url_template,omitempty" table:"-"`
// Health checks (JSON-only)
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty" table:"-"`
HealthCheckPath *string `json:"health_check_path,omitempty" table:"-"`
HealthCheckPort *string `json:"health_check_port,omitempty" table:"-"`
HealthCheckHost *string `json:"health_check_host,omitempty" table:"-"`
HealthCheckMethod *string `json:"health_check_method,omitempty" table:"-"`
HealthCheckScheme *string `json:"health_check_scheme,omitempty" table:"-"`
HealthCheckReturnCode *int `json:"health_check_return_code,omitempty" table:"-"`
HealthCheckResponseText *string `json:"health_check_response_text,omitempty" table:"-"`
HealthCheckInterval *int `json:"health_check_interval,omitempty" table:"-"`
HealthCheckTimeout *int `json:"health_check_timeout,omitempty" table:"-"`
HealthCheckRetries *int `json:"health_check_retries,omitempty" table:"-"`
HealthCheckStartPeriod *int `json:"health_check_start_period,omitempty" table:"-"`
// Resource limits (JSON-only)
LimitsCPUs *string `json:"limits_cpus,omitempty" table:"-"`
LimitsCPUShares *int `json:"limits_cpu_shares,omitempty" table:"-"`
LimitsCPUSet *string `json:"limits_cpuset,omitempty" table:"-"`
LimitsMemory *string `json:"limits_memory,omitempty" table:"-"`
LimitsMemoryReservation *string `json:"limits_memory_reservation,omitempty" table:"-"`
LimitsMemorySwap *string `json:"limits_memory_swap,omitempty" table:"-"`
LimitsMemorySwappiness *int `json:"limits_memory_swappiness,omitempty" table:"-"`
// Deployment hooks (JSON-only)
PreDeploymentCommand *string `json:"pre_deployment_command,omitempty" table:"-"`
PreDeploymentCommandContainer *string `json:"pre_deployment_command_container,omitempty" table:"-"`
PostDeploymentCommand *string `json:"post_deployment_command,omitempty" table:"-"`
PostDeploymentCommandContainer *string `json:"post_deployment_command_container,omitempty" table:"-"`
// Webhook secrets (JSON-only)
ManualWebhookSecretGitHub *string `json:"manual_webhook_secret_github,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretGitLab *string `json:"manual_webhook_secret_gitlab,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretBitbucket *string `json:"manual_webhook_secret_bitbucket,omitempty" table:"-" sensitive:"true"`
ManualWebhookSecretGitea *string `json:"manual_webhook_secret_gitea,omitempty" table:"-" sensitive:"true"`
// Misc (JSON-only)
WatchPaths *string `json:"watch_paths,omitempty" table:"-"`
SwarmReplicas *int `json:"swarm_replicas,omitempty" table:"-"`
ConfigHash *string `json:"config_hash,omitempty" table:"-"`
// Nested settings (JSON-only)
Settings *ApplicationSettings `json:"settings,omitempty" table:"-"`
// Hidden fields (not in JSON or table output)
ID int `json:"-" table:"-"`
EnvironmentID *int `json:"-" table:"-"`
DestinationID *int `json:"-" table:"-"`
SourceID *int `json:"-" table:"-"`
PrivateKeyID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// ApplicationListItem represents a simplified application for list view
@@ -43,6 +143,7 @@ type ApplicationUpdateRequest struct {
// Docker configuration
Dockerfile *string `json:"dockerfile,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
DockerRegistryImageName *string `json:"docker_registry_image_name,omitempty"`
DockerRegistryImageTag *string `json:"docker_registry_image_tag,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
@@ -106,6 +207,7 @@ type EnvironmentVariable struct {
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
ApplicationID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
@@ -114,13 +216,14 @@ type EnvironmentVariable struct {
// EnvironmentVariableCreateRequest represents the request to create an environment variable
type EnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsPreview *bool `json:"is_preview,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// EnvironmentVariableUpdateRequest represents the request to update an environment variable
@@ -132,6 +235,131 @@ type EnvironmentVariableUpdateRequest struct {
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// StoragesResponse represents the API response for listing storages
type StoragesResponse struct {
PersistentStorages []PersistentStorage `json:"persistent_storages"`
FileStorages []FileStorage `json:"file_storages"`
}
// PersistentStorage represents a persistent volume for an application
type PersistentStorage struct {
ID int `json:"id" table:"-"`
UUID string `json:"uuid"`
Name string `json:"name"`
MountPath string `json:"mount_path"`
HostPath *string `json:"host_path,omitempty"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
IsReadOnly bool `json:"is_readonly"`
ResourceType string `json:"resource_type" table:"-"`
ResourceID int `json:"resource_id" table:"-"`
}
// FileStorage represents a file storage for an application
type FileStorage struct {
ID int `json:"id"`
UUID string `json:"uuid"`
FsPath string `json:"fs_path"`
MountPath string `json:"mount_path"`
Content *string `json:"content,omitempty"`
IsDirectory bool `json:"is_directory"`
IsBasedOnGit bool `json:"is_based_on_git"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
Chown *string `json:"chown,omitempty"`
Chmod *string `json:"chmod,omitempty"`
ResourceType string `json:"resource_type" table:"-"`
ResourceID int `json:"resource_id" table:"-"`
}
// StorageListItem is a unified view of both storage types for table output
type StorageListItem struct {
ID int `json:"id" table:"-"`
UUID string `json:"uuid"`
Type string `json:"type"`
Name string `json:"name"`
MountPath string `json:"mount_path"`
HostPath string `json:"host_path,omitempty"`
IsPreviewSuffixEnabled bool `json:"is_preview_suffix_enabled"`
Content string `json:"content,omitempty" table:"-"`
}
// MergeStorages converts a StoragesResponse into a unified list of StorageListItem
func MergeStorages(resp StoragesResponse) []StorageListItem {
var items []StorageListItem
for _, ps := range resp.PersistentStorages {
hostPath := ""
if ps.HostPath != nil {
hostPath = *ps.HostPath
}
items = append(items, StorageListItem{
ID: ps.ID,
UUID: ps.UUID,
Type: "persistent",
Name: ps.Name,
MountPath: ps.MountPath,
HostPath: hostPath,
IsPreviewSuffixEnabled: ps.IsPreviewSuffixEnabled,
})
}
for _, fs := range resp.FileStorages {
content := ""
if fs.Content != nil {
content = *fs.Content
}
items = append(items, StorageListItem{
ID: fs.ID,
UUID: fs.UUID,
Type: "file",
Name: fs.FsPath,
MountPath: fs.MountPath,
IsPreviewSuffixEnabled: fs.IsPreviewSuffixEnabled,
Content: content,
})
}
return items
}
// StorageCreateRequest represents the request to create a storage for applications and databases
type StorageCreateRequest struct {
Type string `json:"type"` // "persistent" or "file"
MountPath string `json:"mount_path"` // required
Name *string `json:"name,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
IsDirectory *bool `json:"is_directory,omitempty"`
FsPath *string `json:"fs_path,omitempty"`
}
// ServiceStorageCreateRequest represents the request to create a storage for services
// Services require resource_uuid to identify which sub-resource the storage belongs to
type ServiceStorageCreateRequest struct {
Type string `json:"type"` // "persistent" or "file"
MountPath string `json:"mount_path"` // required
ResourceUUID string `json:"resource_uuid"` // required for services
Name *string `json:"name,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
IsDirectory *bool `json:"is_directory,omitempty"`
FsPath *string `json:"fs_path,omitempty"`
}
// StorageUpdateRequest represents the request to update a storage
type StorageUpdateRequest struct {
// Required fields
Type string `json:"type"` // "persistent" or "file"
// Identifier (uuid preferred, id deprecated)
UUID *string `json:"uuid,omitempty"`
ID *int `json:"id,omitempty"`
// Optional fields
IsPreviewSuffixEnabled *bool `json:"is_preview_suffix_enabled,omitempty"`
Name *string `json:"name,omitempty"`
MountPath *string `json:"mount_path,omitempty"`
HostPath *string `json:"host_path,omitempty"`
Content *string `json:"content,omitempty"`
}
// ApplicationCreatePublicRequest for POST /applications/public
@@ -164,6 +392,7 @@ type ApplicationCreatePublicRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
// Health checks
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
@@ -207,6 +436,7 @@ type ApplicationCreateGitHubAppRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -246,6 +476,7 @@ type ApplicationCreateDeployKeyRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -276,6 +507,7 @@ type ApplicationCreateDockerfileRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
@@ -307,6 +539,7 @@ type ApplicationCreateDockerImageRequest struct {
PortsMappings *string `json:"ports_mappings,omitempty"`
CustomDockerRunOptions *string `json:"custom_docker_run_options,omitempty"`
CustomLabels *string `json:"custom_labels,omitempty"`
DockerfileTargetBuild *string `json:"dockerfile_target_build,omitempty"`
HealthCheckEnabled *bool `json:"health_check_enabled,omitempty"`
HealthCheckPath *string `json:"health_check_path,omitempty"`
HealthCheckPort *string `json:"health_check_port,omitempty"`
+46
View File
@@ -164,6 +164,52 @@ type DatabaseLifecycleResponse struct {
Message string `json:"message"`
}
// DatabaseEnvironmentVariable represents an environment variable for a database
type DatabaseEnvironmentVariable struct {
ID int `json:"-" table:"-"`
UUID string `json:"uuid"`
Key string `json:"key"`
Value string `json:"value" sensitive:"true"`
IsBuildTime bool `json:"is_buildtime"`
IsLiteralValue bool `json:"is_literal"`
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
DatabaseID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
UpdatedAt string `json:"-" table:"-"`
}
// DatabaseEnvironmentVariableCreateRequest represents the request to create a database environment variable
type DatabaseEnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsShownOnce *bool `json:"is_shown_once,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// DatabaseEnvironmentVariableUpdateRequest represents the request to update a database environment variable
type DatabaseEnvironmentVariableUpdateRequest struct {
Key *string `json:"key,omitempty"`
Value *string `json:"value,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsShownOnce *bool `json:"is_shown_once,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// DatabaseEnvBulkUpdateRequest represents the request to bulk update database environment variables
type DatabaseEnvBulkUpdateRequest struct {
Data []DatabaseEnvironmentVariableCreateRequest `json:"data"`
}
// DatabaseEnvBulkUpdateResponse represents the response from database bulk update
type DatabaseEnvBulkUpdateResponse []DatabaseEnvironmentVariable
// DatabaseBackup represents a scheduled database backup configuration
type DatabaseBackup struct {
ID int `json:"-" table:"-"`
+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"`
}
+120
View File
@@ -99,6 +99,126 @@ func TestProject_UnmarshalFromFixture(t *testing.T) {
assert.Equal(t, "running", project.Environments[0].Applications[0].Status)
}
func TestApplication_MarshalUnmarshal(t *testing.T) {
desc := "Test application"
repo := "https://github.com/example/app"
branch := "main"
fqdn := "https://app.example.com"
buildPack := "nixpacks"
ports := "3000"
installCmd := "npm install"
buildCmd := "npm run build"
startCmd := "npm start"
limitsCPUs := "2"
limitsMemory := "512M"
healthPath := "/health"
isAutoDeployEnabled := true
app := Application{
ID: 1,
UUID: "app-uuid",
Name: "My App",
Description: &desc,
Status: "running",
FQDN: &fqdn,
GitRepository: &repo,
GitBranch: &branch,
BuildPack: &buildPack,
PortsExposes: &ports,
InstallCommand: &installCmd,
BuildCommand: &buildCmd,
StartCommand: &startCmd,
LimitsCPUs: &limitsCPUs,
LimitsMemory: &limitsMemory,
HealthCheckPath: &healthPath,
Settings: &ApplicationSettings{
IsAutoDeployEnabled: &isAutoDeployEnabled,
},
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
// Marshal
data, err := json.Marshal(app)
require.NoError(t, err)
// Unmarshal
var unmarshaled Application
err = json.Unmarshal(data, &unmarshaled)
require.NoError(t, err)
assert.Equal(t, app.UUID, unmarshaled.UUID)
assert.Equal(t, app.Name, unmarshaled.Name)
assert.Equal(t, *app.GitRepository, *unmarshaled.GitRepository)
assert.Equal(t, *app.BuildPack, *unmarshaled.BuildPack)
assert.Equal(t, *app.InstallCommand, *unmarshaled.InstallCommand)
assert.Equal(t, *app.LimitsCPUs, *unmarshaled.LimitsCPUs)
assert.NotNil(t, unmarshaled.Settings)
assert.True(t, *unmarshaled.Settings.IsAutoDeployEnabled)
}
func TestApplication_UnmarshalFromFixture(t *testing.T) {
fixtureData, err := os.ReadFile(filepath.Join("..", "..", "test", "fixtures", "application.json"))
require.NoError(t, err)
var app Application
err = json.Unmarshal(fixtureData, &app)
require.NoError(t, err)
assert.Equal(t, "app-fixture-uuid-123", app.UUID)
assert.Equal(t, "My Web Application", app.Name)
assert.Equal(t, "running", app.Status)
assert.Equal(t, "https://app.example.com", *app.FQDN)
assert.Equal(t, "https://github.com/example/app", *app.GitRepository)
assert.Equal(t, "main", *app.GitBranch)
assert.Equal(t, "nixpacks", *app.BuildPack)
assert.Equal(t, "3000", *app.PortsExposes)
assert.Equal(t, "npm install", *app.InstallCommand)
assert.Equal(t, "npm run build", *app.BuildCommand)
assert.Equal(t, "npm start", *app.StartCommand)
assert.Equal(t, "/health", *app.HealthCheckPath)
assert.Equal(t, 200, *app.HealthCheckReturnCode)
assert.Equal(t, "2", *app.LimitsCPUs)
assert.Equal(t, "512M", *app.LimitsMemory)
assert.Equal(t, "npm run migrate", *app.PreDeploymentCommand)
assert.Equal(t, 1, *app.SwarmReplicas)
assert.Equal(t, "abc123", *app.ConfigHash)
// Nested settings
require.NotNil(t, app.Settings)
assert.NotNil(t, app.Settings.IsAutoDeployEnabled)
assert.True(t, *app.Settings.IsAutoDeployEnabled)
assert.True(t, *app.Settings.IsForceHTTPSEnabled)
assert.False(t, *app.Settings.IsStatic)
}
func TestApplication_JSONExcludesHiddenFields(t *testing.T) {
app := Application{
ID: 42,
UUID: "app-uuid",
Name: "Test App",
Status: "running",
EnvironmentID: intPtr(1),
DestinationID: intPtr(2),
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
data, err := json.Marshal(app)
require.NoError(t, err)
jsonStr := string(data)
assert.NotContains(t, jsonStr, `"id"`)
assert.NotContains(t, jsonStr, `"environment_id"`)
assert.NotContains(t, jsonStr, `"destination_id"`)
assert.NotContains(t, jsonStr, `"created_at"`)
assert.NotContains(t, jsonStr, `"updated_at"`)
assert.Contains(t, jsonStr, `"uuid"`)
assert.Contains(t, jsonStr, `"name"`)
}
func intPtr(v int) *int { return &v }
func TestResource_MarshalUnmarshal(t *testing.T) {
resource := Resource{
ID: 1,
+9 -6
View File
@@ -82,6 +82,7 @@ type ServiceEnvironmentVariable struct {
IsShownOnce bool `json:"is_shown_once"`
IsRuntime bool `json:"is_runtime"`
IsShared bool `json:"is_shared"`
Comment *string `json:"comment,omitempty"`
RealValue *string `json:"real_value,omitempty" sensitive:"true"`
ServiceID *int `json:"-" table:"-"`
CreatedAt string `json:"-" table:"-"`
@@ -90,12 +91,13 @@ type ServiceEnvironmentVariable struct {
// ServiceEnvironmentVariableCreateRequest represents the request to create a service environment variable
type ServiceEnvironmentVariableCreateRequest struct {
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Key string `json:"key"`
Value string `json:"value"`
IsBuildTime *bool `json:"is_build_time,omitempty"`
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ServiceEnvironmentVariableUpdateRequest represents the request to update a service environment variable
@@ -106,6 +108,7 @@ type ServiceEnvironmentVariableUpdateRequest struct {
IsLiteral *bool `json:"is_literal,omitempty"`
IsMultiline *bool `json:"is_multiline,omitempty"`
IsRuntime *bool `json:"is_runtime,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ServiceEnvBulkUpdateRequest represents the request to bulk update service environment variables
+17 -6
View File
@@ -210,12 +210,23 @@ func TestTableFormatter_BooleanValues(t *testing.T) {
output := buf.String()
// Check boolean formatting
lines := strings.Split(output, "\n")
assert.Contains(t, lines[1], "true")
assert.Contains(t, lines[1], "false")
assert.Contains(t, lines[2], "false")
assert.Contains(t, lines[2], "true")
// Check boolean formatting - verify both rows contain expected values
// Row 1: test1, true, false
assert.Contains(t, output, "test1")
assert.Contains(t, output, "test2")
// Find lines containing test data (not headers/borders)
var dataLines []string
for _, line := range strings.Split(output, "\n") {
if strings.Contains(line, "test1") || strings.Contains(line, "test2") {
dataLines = append(dataLines, line)
}
}
require.Len(t, dataLines, 2, "expected exactly 2 data rows")
assert.Contains(t, dataLines[0], "true")
assert.Contains(t, dataLines[0], "false")
assert.Contains(t, dataLines[1], "true")
assert.Contains(t, dataLines[1], "false")
}
func TestTableFormatter_NilPointer(t *testing.T) {
+34 -39
View File
@@ -4,7 +4,9 @@ import (
"fmt"
"reflect"
"strings"
"text/tabwriter"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
)
// TableFormatter formats output as a table
@@ -18,21 +20,6 @@ func NewTableFormatter(opts Options) *TableFormatter {
}
func (f *TableFormatter) Format(data any) (err error) {
w := tabwriter.NewWriter(f.opts.Writer, 0, 0, 2, ' ', tabwriter.Debug)
defer func() {
if flushErr := w.Flush(); flushErr != nil {
if err == nil {
err = fmt.Errorf("failed to flush table writer: %w", flushErr)
}
}
// Add a final newline nach table output, but only if no error occurred
if err == nil {
if _, nlErr := fmt.Fprintln(f.opts.Writer); nlErr != nil {
err = fmt.Errorf("failed to write trailing newline: %w", nlErr)
}
}
}()
// Handle different data types
val := reflect.ValueOf(data)
@@ -41,6 +28,24 @@ func (f *TableFormatter) Format(data any) (err error) {
val = val.Elem()
}
// Check for empty slice/array before creating the table writer
if (val.Kind() == reflect.Slice || val.Kind() == reflect.Array) && val.Len() == 0 {
if _, writeErr := fmt.Fprintln(f.opts.Writer, "No data"); writeErr != nil {
return fmt.Errorf("failed to write no data message: %w", writeErr)
}
return nil
}
w := tablewriter.NewWriter(f.opts.Writer)
defer func() {
if renderErr := w.Render(); renderErr != nil && err == nil {
err = fmt.Errorf("failed to render table: %w", renderErr)
}
}()
// disable ALL CAPS for column headers
w.Options(tablewriter.WithHeaderAutoFormat(tw.Off))
switch val.Kind() {
case reflect.Slice, reflect.Array:
return f.formatSlice(w, val)
@@ -54,14 +59,7 @@ func (f *TableFormatter) Format(data any) (err error) {
}
// formatSlice formats a slice of structs as a table
func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) error {
if val.Len() == 0 {
if _, err := fmt.Fprintln(w, "No data"); err != nil {
return fmt.Errorf("failed to write no data message: %w", err)
}
return nil
}
func (f *TableFormatter) formatSlice(w *tablewriter.Table, val reflect.Value) error {
// Get the first element to determine columns
firstElem := val.Index(0)
if firstElem.Kind() == reflect.Ptr {
@@ -71,7 +69,9 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
if firstElem.Kind() != reflect.Struct {
// Simple slice (e.g., []string)
for i := 0; i < val.Len(); i++ {
if _, err := fmt.Fprintf(w, "%v\n", val.Index(i).Interface()); err != nil {
elem := val.Index(i)
row := []string{f.formatValue(elem)}
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write slice element: %w", err)
}
}
@@ -82,9 +82,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
headers := f.getHeaders(firstElem.Type())
// Add # as first column header
headersWithNum := append([]string{"#"}, headers...)
if _, err := fmt.Fprintln(w, strings.Join(headersWithNum, "\t")); err != nil {
return fmt.Errorf("failed to write table headers: %w", err)
}
w.Header(headersWithNum)
// Print rows
for i := 0; i < val.Len(); i++ {
@@ -95,7 +93,7 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
row := f.formatStructRow(elem)
// Add row number (1-indexed) as first column
rowWithNum := append([]string{fmt.Sprintf("%d", i+1)}, row...)
if _, err := fmt.Fprintln(w, strings.Join(rowWithNum, "\t")); err != nil {
if err := w.Append(rowWithNum); err != nil {
return fmt.Errorf("failed to write table row: %w", err)
}
}
@@ -104,16 +102,14 @@ func (f *TableFormatter) formatSlice(w *tabwriter.Writer, val reflect.Value) err
}
// formatStruct formats a single struct as a table (horizontal layout with headers)
func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) error {
func (f *TableFormatter) formatStruct(w *tablewriter.Table, val reflect.Value) error {
// Get headers
headers := f.getHeaders(val.Type())
if _, err := fmt.Fprintln(w, strings.Join(headers, "\t")); err != nil {
return fmt.Errorf("failed to write struct headers: %w", err)
}
w.Header(headers)
// Get row data
row := f.formatStructRow(val)
if _, err := fmt.Fprintln(w, strings.Join(row, "\t")); err != nil {
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write struct row: %w", err)
}
@@ -121,16 +117,15 @@ func (f *TableFormatter) formatStruct(w *tabwriter.Writer, val reflect.Value) er
}
// formatMap formats a map as a table
func (f *TableFormatter) formatMap(w *tabwriter.Writer, val reflect.Value) error {
if _, err := fmt.Fprintln(w, "Key\tValue"); err != nil {
return fmt.Errorf("failed to write map headers: %w", err)
}
func (f *TableFormatter) formatMap(w *tablewriter.Table, val reflect.Value) error {
w.Header([]string{"Key", "Value"})
iter := val.MapRange()
for iter.Next() {
key := iter.Key()
value := iter.Value()
if _, err := fmt.Fprintf(w, "%v\t%v\n", key.Interface(), f.formatValue(value)); err != nil {
row := []string{f.formatValue(key), f.formatValue(value)}
if err := w.Append(row); err != nil {
return fmt.Errorf("failed to write map entry: %w", err)
}
}
+38 -3
View File
@@ -184,9 +184,7 @@ type BulkUpdateEnvsRequest struct {
}
// BulkUpdateEnvsResponse represents the response from bulk update
type BulkUpdateEnvsResponse struct {
Message string `json:"message"`
}
type BulkUpdateEnvsResponse []models.EnvironmentVariable
// BulkUpdateEnvs updates multiple environment variables in a single request
func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string, req *BulkUpdateEnvsRequest) (*BulkUpdateEnvsResponse, error) {
@@ -198,6 +196,43 @@ func (s *ApplicationService) BulkUpdateEnvs(ctx context.Context, appUUID string,
return &response, nil
}
// ListStorages retrieves all storages for an application
func (s *ApplicationService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("applications/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for application %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for an application
func (s *ApplicationService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for application %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for an application
func (s *ApplicationService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("applications/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for application %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from an application
func (s *ApplicationService) DeleteStorage(ctx context.Context, appUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("applications/%s/storages/%s", appUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from application %s: %w", storageUUID, appUUID, err)
}
return nil
}
// CreatePublic creates an application from a public git repository
func (s *ApplicationService) CreatePublic(ctx context.Context, req *models.ApplicationCreatePublicRequest) (*models.Application, error) {
var app models.Application
+380 -14
View File
@@ -110,17 +110,41 @@ func TestApplicationService_Get(t *testing.T) {
desc := "Test Application"
branch := "main"
fqdn := "test.example.com"
repo := "https://github.com/example/app"
buildPack := "nixpacks"
ports := "3000"
installCmd := "npm install"
buildCmd := "npm run build"
startCmd := "npm start"
healthPath := "/health"
limitsCPUs := "2"
limitsMemory := "512M"
preDeployCmd := "npm run migrate"
isAutoDeployEnabled := true
application := models.Application{
ID: 1,
UUID: "app-uuid-123",
Name: "Test App",
Description: &desc,
Status: "running",
GitBranch: &branch,
FQDN: &fqdn,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
ID: 1,
UUID: "app-uuid-123",
Name: "Test App",
Description: &desc,
Status: "running",
GitBranch: &branch,
FQDN: &fqdn,
GitRepository: &repo,
BuildPack: &buildPack,
PortsExposes: &ports,
InstallCommand: &installCmd,
BuildCommand: &buildCmd,
StartCommand: &startCmd,
HealthCheckPath: &healthPath,
LimitsCPUs: &limitsCPUs,
LimitsMemory: &limitsMemory,
PreDeploymentCommand: &preDeployCmd,
Settings: &models.ApplicationSettings{
IsAutoDeployEnabled: &isAutoDeployEnabled,
},
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -144,6 +168,18 @@ func TestApplicationService_Get(t *testing.T) {
assert.Equal(t, "running", result.Status)
assert.Equal(t, "main", *result.GitBranch)
assert.Equal(t, "test.example.com", *result.FQDN)
assert.Equal(t, "https://github.com/example/app", *result.GitRepository)
assert.Equal(t, "nixpacks", *result.BuildPack)
assert.Equal(t, "3000", *result.PortsExposes)
assert.Equal(t, "npm install", *result.InstallCommand)
assert.Equal(t, "npm run build", *result.BuildCommand)
assert.Equal(t, "npm start", *result.StartCommand)
assert.Equal(t, "/health", *result.HealthCheckPath)
assert.Equal(t, "2", *result.LimitsCPUs)
assert.Equal(t, "512M", *result.LimitsMemory)
assert.Equal(t, "npm run migrate", *result.PreDeploymentCommand)
require.NotNil(t, result.Settings)
assert.True(t, *result.Settings.IsAutoDeployEnabled)
}
func TestApplicationService_Get_NotFound(t *testing.T) {
@@ -724,10 +760,12 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
comment := "API key for external service"
env := models.EnvironmentVariable{
UUID: "env-uuid-1",
Key: "API_KEY",
Value: "newsecret456",
UUID: "env-uuid-1",
Key: "API_KEY",
Value: "newsecret456",
Comment: &comment,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(env)
@@ -739,9 +777,11 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
newKey := "API_KEY"
newValue := "newsecret456"
newComment := "API key for external service"
req := &models.EnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
Key: &newKey,
Value: &newValue,
Comment: &newComment,
}
result, err := svc.UpdateEnv(context.Background(), "app-uuid-123", req)
@@ -749,6 +789,7 @@ func TestApplicationService_UpdateEnv(t *testing.T) {
assert.NotNil(t, result)
assert.Equal(t, "API_KEY", result.Key)
assert.Equal(t, "newsecret456", result.Value)
assert.Equal(t, "API key for external service", *result.Comment)
}
func TestApplicationService_UpdateEnv_Error(t *testing.T) {
@@ -1196,3 +1237,328 @@ func TestApplicationService_CreateDockerImage(t *testing.T) {
assert.NotNil(t, result)
assert.Equal(t, "new-app-uuid", result.UUID)
}
func TestApplicationService_BulkUpdateEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/envs/bulk", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
// Verify request body was correctly serialized
var reqBody BulkUpdateEnvsRequest
err := json.NewDecoder(r.Body).Decode(&reqBody)
assert.NoError(t, err)
assert.Len(t, reqBody.Data, 1)
assert.Equal(t, "KEY1", reqBody.Data[0].Key)
assert.Equal(t, "VAL1", reqBody.Data[0].Value)
// Return array response as per API
w.Header().Set("Content-Type", "application/json")
resp := []models.EnvironmentVariable{
{
Key: "KEY1",
Value: "VAL1",
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &BulkUpdateEnvsRequest{
Data: []models.EnvironmentVariableCreateRequest{
{Key: "KEY1", Value: "VAL1"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Len(t, *result, 1)
assert.Equal(t, "KEY1", (*result)[0].Key)
}
func TestApplicationService_BulkUpdateEnvs_APIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &BulkUpdateEnvsRequest{
Data: []models.EnvironmentVariableCreateRequest{
{Key: "KEY1", Value: "VAL1"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to bulk update environment variables")
}
func TestApplicationService_ListStorages(t *testing.T) {
hostPath := "/var/data"
content := "key: value"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: false,
IsReadOnly: false,
ResourceType: "App\\Models\\Application",
ResourceID: 10,
},
},
FileStorages: []models.FileStorage{
{
ID: 2,
UUID: "fs-uuid-1",
FsPath: "/app/config.yml",
MountPath: "/app/config.yml",
Content: &content,
IsDirectory: false,
IsBasedOnGit: false,
IsPreviewSuffixEnabled: true,
ResourceType: "App\\Models\\Application",
ResourceID: 10,
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "ps-uuid-1", result[0].UUID)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.Equal(t, "/data", result[0].MountPath)
assert.Equal(t, "/var/data", result[0].HostPath)
assert.False(t, result[0].IsPreviewSuffixEnabled)
assert.Equal(t, "fs-uuid-1", result[1].UUID)
assert.Equal(t, "file", result[1].Type)
assert.Equal(t, "/app/config.yml", result[1].Name)
assert.Equal(t, "key: value", result[1].Content)
assert.True(t, result[1].IsPreviewSuffixEnabled)
}
func TestApplicationService_ListStorages_Empty(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"persistent_storages":[],"file_storages":[]}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.NoError(t, err)
assert.Empty(t, result)
}
func TestApplicationService_ListStorages_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"application not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
result, err := svc.ListStorages(context.Background(), "app-uuid-123")
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to list storages")
}
func TestApplicationService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.NotNil(t, req.UUID)
assert.Equal(t, "storage-uuid-1", *req.UUID)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.Name)
assert.Equal(t, "new-name", *req.Name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"message":"Storage updated."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
}
func TestApplicationService_UpdateStorage_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"application not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "non-existent-uuid", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to update storage")
}
func TestApplicationService_UpdateStorage_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message":"internal server error"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "new-name"
storageUUID := "storage-uuid-1"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to update storage")
}
func TestApplicationService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
var req models.StorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
assert.NotNil(t, req.Name)
assert.Equal(t, "my-volume", *req.Name)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
name := "my-volume"
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
require.NoError(t, err)
}
func TestApplicationService_CreateStorage_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message":"invalid request"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
}
err := svc.CreateStorage(context.Background(), "app-uuid-123", req)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to create storage")
}
func TestApplicationService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/applications/app-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
func TestApplicationService_DeleteStorage_NotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"storage not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewApplicationService(client)
err := svc.DeleteStorage(context.Background(), "app-uuid-123", "nonexistent-uuid")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to delete storage")
}
+102
View File
@@ -173,6 +173,108 @@ func (s *DatabaseService) DeleteBackupExecution(ctx context.Context, dbUUID, bac
return nil
}
// ListEnvs retrieves all environment variables for a database
func (s *DatabaseService) ListEnvs(ctx context.Context, uuid string) ([]models.DatabaseEnvironmentVariable, error) {
var envs []models.DatabaseEnvironmentVariable
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/envs", uuid), &envs)
if err != nil {
return nil, fmt.Errorf("failed to list environment variables for database %s: %w", uuid, err)
}
return envs, nil
}
// GetEnv retrieves a single environment variable by UUID or key
func (s *DatabaseService) GetEnv(ctx context.Context, dbUUID, envIdentifier string) (*models.DatabaseEnvironmentVariable, error) {
envs, err := s.ListEnvs(ctx, dbUUID)
if err != nil {
return nil, err
}
for _, env := range envs {
if env.UUID == envIdentifier || env.Key == envIdentifier {
return &env, nil
}
}
return nil, fmt.Errorf("environment variable '%s' not found in database %s", envIdentifier, dbUUID)
}
// CreateEnv creates a new environment variable for a database
func (s *DatabaseService) CreateEnv(ctx context.Context, uuid string, req *models.DatabaseEnvironmentVariableCreateRequest) (*models.DatabaseEnvironmentVariable, error) {
var env models.DatabaseEnvironmentVariable
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/envs", uuid), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to create environment variable for database %s: %w", uuid, err)
}
return &env, nil
}
// UpdateEnv updates an environment variable for a database
func (s *DatabaseService) UpdateEnv(ctx context.Context, dbUUID string, req *models.DatabaseEnvironmentVariableUpdateRequest) (*models.DatabaseEnvironmentVariable, error) {
var env models.DatabaseEnvironmentVariable
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs", dbUUID), req, &env)
if err != nil {
return nil, fmt.Errorf("failed to update environment variable for database %s: %w", dbUUID, err)
}
return &env, nil
}
// DeleteEnv deletes an environment variable from a database
func (s *DatabaseService) DeleteEnv(ctx context.Context, dbUUID, envUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/envs/%s", dbUUID, envUUID))
if err != nil {
return fmt.Errorf("failed to delete environment variable %s from database %s: %w", envUUID, dbUUID, err)
}
return nil
}
// BulkUpdateEnvs updates multiple environment variables for a database in a single request
func (s *DatabaseService) BulkUpdateEnvs(ctx context.Context, dbUUID string, req *models.DatabaseEnvBulkUpdateRequest) (models.DatabaseEnvBulkUpdateResponse, error) {
var response models.DatabaseEnvBulkUpdateResponse
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/envs/bulk", dbUUID), req, &response)
if err != nil {
return nil, fmt.Errorf("failed to bulk update environment variables for database %s: %w", dbUUID, err)
}
return response, nil
}
// ListStorages retrieves all storages for a database
func (s *DatabaseService) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("databases/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for database %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for a database
func (s *DatabaseService) CreateStorage(ctx context.Context, uuid string, req *models.StorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for database %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for a database
func (s *DatabaseService) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("databases/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for database %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from a database
func (s *DatabaseService) DeleteStorage(ctx context.Context, dbUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("databases/%s/storages/%s", dbUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from database %s: %w", storageUUID, dbUUID, err)
}
return nil
}
// inferDatabaseType determines the database type from available fields
func inferDatabaseType(db *models.Database) string {
// Check for PostgreSQL
+367
View File
@@ -848,6 +848,252 @@ func TestDatabaseService_DeleteBackupExecution(t *testing.T) {
}
}
// --- Environment Variable Tests ---
func TestDatabaseService_ListEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost", "is_literal": false},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432", "is_literal": true}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
require.NoError(t, err)
assert.Len(t, envs, 2)
assert.Equal(t, "DB_HOST", envs[0].Key)
assert.Equal(t, "DB_PORT", envs[1].Key)
}
func TestDatabaseService_ListEnvs_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"database not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
envs, err := svc.ListEnvs(context.Background(), "db-uuid-123")
require.Error(t, err)
assert.Nil(t, envs)
assert.Contains(t, err.Error(), "failed to list environment variables")
}
func TestDatabaseService_GetEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
// Find by UUID
env, err := svc.GetEnv(context.Background(), "db-uuid-123", "env-2")
require.NoError(t, err)
assert.Equal(t, "DB_PORT", env.Key)
// Find by key
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "DB_HOST")
require.NoError(t, err)
assert.Equal(t, "env-1", env.UUID)
// Not found
env, err = svc.GetEnv(context.Background(), "db-uuid-123", "NONEXISTENT")
require.Error(t, err)
assert.Nil(t, env)
assert.Contains(t, err.Error(), "not found")
}
func TestDatabaseService_CreateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.DatabaseEnvironmentVariableCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "NEW_VAR", req.Key)
assert.Equal(t, "new_value", req.Value)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"uuid": "env-new",
"key": "NEW_VAR",
"value": "new_value",
"comment": "test comment"
}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
comment := "test comment"
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
Key: "NEW_VAR",
Value: "new_value",
Comment: &comment,
})
require.NoError(t, err)
assert.Equal(t, "NEW_VAR", env.Key)
assert.Equal(t, "new_value", env.Value)
assert.Equal(t, "test comment", *env.Comment)
}
func TestDatabaseService_CreateEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"validation failed"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
env, err := svc.CreateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableCreateRequest{
Key: "NEW_VAR",
Value: "new_value",
})
require.Error(t, err)
assert.Nil(t, env)
assert.Contains(t, err.Error(), "failed to create environment variable")
}
func TestDatabaseService_UpdateEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
comment := "updated comment"
env := models.DatabaseEnvironmentVariable{
UUID: "env-1",
Key: "DB_HOST",
Value: "newhost",
Comment: &comment,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(env)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
newKey := "DB_HOST"
newValue := "newhost"
newComment := "updated comment"
req := &models.DatabaseEnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
Comment: &newComment,
}
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, "DB_HOST", result.Key)
assert.Equal(t, "newhost", result.Value)
assert.Equal(t, "updated comment", *result.Comment)
}
func TestDatabaseService_UpdateEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"environment variable not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
newKey := "DB_HOST"
newValue := "newhost"
result, err := svc.UpdateEnv(context.Background(), "db-uuid-123", &models.DatabaseEnvironmentVariableUpdateRequest{
Key: &newKey,
Value: &newValue,
})
require.Error(t, err)
assert.Nil(t, result)
assert.Contains(t, err.Error(), "failed to update environment variable")
}
func TestDatabaseService_DeleteEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/env-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
require.NoError(t, err)
}
func TestDatabaseService_DeleteEnv_Error(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"not found"}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteEnv(context.Background(), "db-uuid-123", "env-1")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to delete environment variable")
}
func TestDatabaseService_BulkUpdateEnvs(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/envs/bulk", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"uuid": "env-1", "key": "DB_HOST", "value": "localhost"},
{"uuid": "env-2", "key": "DB_PORT", "value": "5432"}
]`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
req := &models.DatabaseEnvBulkUpdateRequest{
Data: []models.DatabaseEnvironmentVariableCreateRequest{
{Key: "DB_HOST", Value: "localhost"},
{Key: "DB_PORT", Value: "5432"},
},
}
result, err := svc.BulkUpdateEnvs(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
assert.Len(t, result, 2)
}
func stringPtr(s string) *string {
return &s
}
@@ -855,3 +1101,124 @@ func stringPtr(s string) *string {
func boolPtr(b bool) *bool {
return &b
}
func TestDatabaseService_ListStorages(t *testing.T) {
hostPath := "/var/data"
content := "key: value"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: true,
},
},
FileStorages: []models.FileStorage{
{
ID: 2,
UUID: "fs-uuid-1",
FsPath: "/app/config.yml",
MountPath: "/app/config.yml",
Content: &content,
IsPreviewSuffixEnabled: false,
},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
result, err := svc.ListStorages(context.Background(), "db-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 2)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.True(t, result[0].IsPreviewSuffixEnabled)
assert.Equal(t, "file", result[1].Type)
assert.Equal(t, "/app/config.yml", result[1].Name)
}
func TestDatabaseService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.StorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
name := "my-volume"
req := &models.StorageCreateRequest{
Type: "persistent",
MountPath: "/data",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
}
func TestDatabaseService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.UUID)
assert.Equal(t, "storage-uuid-1", *req.UUID)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
storageUUID := "storage-uuid-1"
name := "new-name"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "db-uuid-123", req)
require.NoError(t, err)
}
func TestDatabaseService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/databases/db-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewDatabaseService(client)
err := svc.DeleteStorage(context.Background(), "db-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
+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
+37
View File
@@ -156,6 +156,43 @@ func (s *Service) DeleteEnv(ctx context.Context, serviceUUID, envUUID string) er
return nil
}
// ListStorages retrieves all storages for a service
func (s *Service) ListStorages(ctx context.Context, uuid string) ([]models.StorageListItem, error) {
var resp models.StoragesResponse
err := s.client.Get(ctx, fmt.Sprintf("services/%s/storages", uuid), &resp)
if err != nil {
return nil, fmt.Errorf("failed to list storages for service %s: %w", uuid, err)
}
return models.MergeStorages(resp), nil
}
// CreateStorage creates a new storage for a service
func (s *Service) CreateStorage(ctx context.Context, uuid string, req *models.ServiceStorageCreateRequest) error {
err := s.client.Post(ctx, fmt.Sprintf("services/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to create storage for service %s: %w", uuid, err)
}
return nil
}
// UpdateStorage updates a storage for a service
func (s *Service) UpdateStorage(ctx context.Context, uuid string, req *models.StorageUpdateRequest) error {
err := s.client.Patch(ctx, fmt.Sprintf("services/%s/storages", uuid), req, nil)
if err != nil {
return fmt.Errorf("failed to update storage for service %s: %w", uuid, err)
}
return nil
}
// DeleteStorage deletes a storage from a service
func (s *Service) DeleteStorage(ctx context.Context, svcUUID, storageUUID string) error {
err := s.client.Delete(ctx, fmt.Sprintf("services/%s/storages/%s", svcUUID, storageUUID))
if err != nil {
return fmt.Errorf("failed to delete storage %s from service %s: %w", storageUUID, svcUUID, err)
}
return nil
}
// BulkUpdateEnvs updates multiple environment variables in a single request
func (s *Service) BulkUpdateEnvs(ctx context.Context, serviceUUID string, req *models.ServiceEnvBulkUpdateRequest) (models.ServiceEnvBulkUpdateResponse, error) {
var response models.ServiceEnvBulkUpdateResponse
+117 -2
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
@@ -320,7 +321,8 @@ func TestService_UpdateEnv(t *testing.T) {
"key": "UPDATED_VAR",
"value": "updated_value",
"is_buildtime": true,
"is_preview": false
"is_preview": false,
"comment": "updated comment"
}`))
}))
defer server.Close()
@@ -329,12 +331,15 @@ func TestService_UpdateEnv(t *testing.T) {
svc := NewService(client)
newKey := "UPDATED_VAR"
newComment := "updated comment"
env, err := svc.UpdateEnv(context.Background(), "service-uuid-123", &models.ServiceEnvironmentVariableUpdateRequest{
Key: &newKey,
Key: &newKey,
Comment: &newComment,
})
require.NoError(t, err)
assert.Equal(t, "UPDATED_VAR", env.Key)
assert.Equal(t, "updated comment", *env.Comment)
}
func TestService_DeleteEnv(t *testing.T) {
@@ -435,3 +440,113 @@ func TestService_Create_Error(t *testing.T) {
require.Error(t, err)
}
func TestService_ListStorages(t *testing.T) {
hostPath := "/var/data"
resp := models.StoragesResponse{
PersistentStorages: []models.PersistentStorage{
{
ID: 1,
UUID: "ps-uuid-1",
Name: "data-volume",
MountPath: "/data",
HostPath: &hostPath,
IsPreviewSuffixEnabled: true,
},
},
FileStorages: []models.FileStorage{},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "GET", r.Method)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
result, err := svc.ListStorages(context.Background(), "svc-uuid-123")
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, "persistent", result[0].Type)
assert.Equal(t, "data-volume", result[0].Name)
assert.True(t, result[0].IsPreviewSuffixEnabled)
}
func TestService_CreateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "POST", r.Method)
var req models.ServiceStorageCreateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.Equal(t, "/data", req.MountPath)
assert.Equal(t, "sub-resource-uuid", req.ResourceUUID)
w.WriteHeader(http.StatusCreated)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
name := "my-volume"
req := &models.ServiceStorageCreateRequest{
Type: "persistent",
MountPath: "/data",
ResourceUUID: "sub-resource-uuid",
Name: &name,
}
err := svc.CreateStorage(context.Background(), "svc-uuid-123", req)
require.NoError(t, err)
}
func TestService_UpdateStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages", r.URL.Path)
assert.Equal(t, "PATCH", r.Method)
var req models.StorageUpdateRequest
_ = json.NewDecoder(r.Body).Decode(&req)
assert.Equal(t, "persistent", req.Type)
assert.NotNil(t, req.UUID)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
storageUUID := "storage-uuid-1"
name := "new-name"
req := &models.StorageUpdateRequest{
UUID: &storageUUID,
Type: "persistent",
Name: &name,
}
err := svc.UpdateStorage(context.Background(), "svc-uuid-123", req)
require.NoError(t, err)
}
func TestService_DeleteStorage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/v1/services/svc-uuid-123/storages/storage-uuid-1", r.URL.Path)
assert.Equal(t, "DELETE", r.Method)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message":"Storage deleted."}`))
}))
defer server.Close()
client := api.NewClient(server.URL, "test-token")
svc := NewService(client)
err := svc.DeleteStorage(context.Background(), "svc-uuid-123", "storage-uuid-1")
require.NoError(t, err)
}
+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.4.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
+133
View File
@@ -0,0 +1,133 @@
# 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: ./llms-full.txt
- Regenerate docs: `go run ./coolify docs llms`
+76
View File
@@ -0,0 +1,76 @@
{
"id": 1,
"uuid": "app-fixture-uuid-123",
"name": "My Web Application",
"description": "A comprehensive test fixture",
"status": "running",
"fqdn": "https://app.example.com",
"git_repository": "https://github.com/example/app",
"git_branch": "main",
"git_commit_sha": "abc123def456",
"git_full_url": "https://github.com/example/app.git",
"build_pack": "nixpacks",
"ports_exposes": "3000",
"ports_mappings": "3000:3000",
"domains": "app.example.com",
"redirect": "www",
"install_command": "npm install",
"build_command": "npm run build",
"start_command": "npm start",
"base_directory": "/",
"publish_directory": "/dist",
"dockerfile": null,
"dockerfile_location": "/Dockerfile",
"docker_registry_image_name": null,
"docker_registry_image_tag": null,
"custom_docker_run_options": null,
"custom_labels": null,
"custom_nginx_configuration": null,
"health_check_enabled": true,
"health_check_path": "/health",
"health_check_port": "3000",
"health_check_host": null,
"health_check_method": "GET",
"health_check_scheme": "http",
"health_check_return_code": 200,
"health_check_response_text": null,
"health_check_interval": 30,
"health_check_timeout": 5,
"health_check_retries": 3,
"health_check_start_period": 10,
"limits_cpus": "2",
"limits_memory": "512M",
"limits_memory_swap": "1G",
"limits_memory_swappiness": 60,
"limits_memory_reservation": "256M",
"limits_cpuset": null,
"limits_cpu_shares": 1024,
"pre_deployment_command": "npm run migrate",
"pre_deployment_command_container": null,
"post_deployment_command": null,
"post_deployment_command_container": null,
"watch_paths": null,
"swarm_replicas": 1,
"config_hash": "abc123",
"environment_id": 1,
"destination_id": 2,
"source_id": null,
"private_key_id": null,
"settings": {
"id": 1,
"application_id": 1,
"is_static": false,
"is_build_server_enabled": false,
"is_preserve_repository_enabled": false,
"is_auto_deploy_enabled": true,
"is_force_https_enabled": true,
"is_debug_enabled": false,
"is_preview_deployments_enabled": true,
"is_git_submodules_enabled": false,
"is_git_lfs_enabled": false,
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
},
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-02T00:00:00Z"
}
+2 -1
View File
@@ -8,5 +8,6 @@
"is_literal": false,
"is_shown_once": false,
"is_runtime": true,
"is_shared": false
"is_shared": false,
"comment": "Primary database connection string"
}